@hubspot/ui-extensions-dev-server 0.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # ui-extensions-dev-server
2
+
3
+ ## Overview
4
+ This package contains the cli for running HubSpot UI extensions locally, as well as running a production build for debugging purposes.
5
+
6
+ ### Help
7
+ If for any reason you need help with the `ui-extensions-dev-server` you can access the help menu in a few ways:
8
+
9
+ - `hs-ui-extensions-dev-server`
10
+ - `hs-ui-extensions-dev-server -h`
11
+ - `hs-ui-extensions-dev-server --help`
12
+
13
+ ### Local Development
14
+ Prompts you which of your extensions you would like to run in local dev mode. Once you have made your selection, it sets up a watcher on the entry point file for that project as well as any child files. Any changes to those files will trigger a local build which will bundle your code and output the bundle in a `dist/` directory inside of your current working directory. To initiate local dev mode in the browser, you just need to navigate to the CRM where your the deployed version of your card lives. The browser will detect that you have the dev server running, and make the connection on your behalf.
15
+
16
+ | Action | Command |
17
+ | - | - |
18
+ | Standard Local Dev | `hs-ui-extensions-dev-server dev` |
19
+ | Bypass Prompt | `hs-ui-extensions-dev-server dev --extension MyEntryPointFile.jsx` <br /> `hs-ui-extensions-dev-server dev -e MyEntryPointFile.jsx`|
20
+
21
+ ### Production Build
22
+ This should not be required in the standard development workflow. It is there to aid debugging the output of an extension. This command will run a build similar to what we will run when these extensions are uploaded. The output will be written into a `dist/` directory in the current working directory.
23
+
24
+ | Action | Command |
25
+ | - | - |
26
+ | Build All Extensions | `hs-ui-extensions-dev-server build` |
27
+ | Build a single extension | `hs-ui-extensions-dev-server build --extension MyEntryPointFile.jsx` <br /> `hs-ui-extensions-dev-server build -e MyEntryPointFile.jsx`|
package/build.js ADDED
@@ -0,0 +1,56 @@
1
+ const { build } = require('vite');
2
+ const { ROLLUP_OPTIONS } = require('./constants');
3
+
4
+ async function buildAllExtensions(config, outputDir) {
5
+ const extensionKeys = Object.keys(config);
6
+ for (let i = 0; i < extensionKeys.length; ++i) {
7
+ const { data } = config[extensionKeys[i]];
8
+
9
+ await buildSingleExtension({
10
+ file: data?.module.file,
11
+ outputFileName: data?.output,
12
+ outputDir,
13
+ emptyOutDir: i === 0,
14
+ });
15
+ }
16
+ }
17
+
18
+ async function buildSingleExtension({
19
+ file,
20
+ outputFileName,
21
+ outputDir,
22
+ emptyOutDir = true,
23
+ plugins = { rollup: [], vite: [] },
24
+ minify = false,
25
+ root = process.cwd(), // This is the vite default, so using that as our default
26
+ }) {
27
+ await build({
28
+ root,
29
+ define: {
30
+ 'process.env.NODE_ENV': JSON.stringify(
31
+ process.env.NODE_ENV || 'production'
32
+ ),
33
+ },
34
+ build: {
35
+ lib: {
36
+ entry: file,
37
+ name: outputFileName,
38
+ formats: ['iife'],
39
+ fileName: () => outputFileName,
40
+ },
41
+ rollupOptions: {
42
+ ...ROLLUP_OPTIONS,
43
+ plugins: [...(ROLLUP_OPTIONS.plugins || []), ...plugins?.rollup],
44
+ },
45
+ outDir: outputDir,
46
+ emptyOutDir,
47
+ minify,
48
+ plugins: plugins?.vite,
49
+ },
50
+ });
51
+ }
52
+
53
+ module.exports = {
54
+ buildAllExtensions,
55
+ buildSingleExtension,
56
+ };
package/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ const logger = require('./logger');
2
+ const commandLineArgs = require('command-line-args');
3
+ const commandLineUsage = require('command-line-usage');
4
+
5
+ function parseArgs() {
6
+ const mainDefinitions = [{ name: 'command', defaultOption: true }];
7
+ const mainOptions = commandLineArgs(mainDefinitions, {
8
+ stopAtFirstUnknown: true,
9
+ });
10
+ const argv = mainOptions._unknown || [];
11
+ const DEV_MODE = mainOptions.command === 'dev';
12
+ const BUILD_MODE = mainOptions.command === 'build';
13
+
14
+ const optionDefinitions = [
15
+ { name: 'port', alias: 'p', type: Number },
16
+ { name: 'extension', alias: 'e', type: String },
17
+ { name: 'help', alias: 'h', type: Boolean },
18
+ ];
19
+
20
+ const options = commandLineArgs(
21
+ optionDefinitions,
22
+ DEV_MODE || BUILD_MODE ? { argv } : {}
23
+ );
24
+
25
+ return { DEV_MODE, BUILD_MODE, ...options };
26
+ }
27
+
28
+ function showHelp(OUTPUT_DIR) {
29
+ const sections = [
30
+ {
31
+ header: 'HubSpot UI Extensions Local Dev Server',
32
+ content: `Used for local development of HubSpot extensions. Built assets can be found in the ${OUTPUT_DIR} directory`,
33
+ },
34
+ {
35
+ header: 'Available Commands',
36
+ content: [
37
+ { name: 'dev', summary: 'starts the local development server' },
38
+ { name: 'build', summary: 'runs a build of your extensions' },
39
+ ],
40
+ },
41
+ {
42
+ header: 'Options',
43
+ optionList: [
44
+ {
45
+ name: 'extension',
46
+ alias: 'e',
47
+ typeLabel: '{underline file}',
48
+ description:
49
+ 'The extension entrypoint file to build or start local development for',
50
+ },
51
+ {
52
+ name: 'help',
53
+ alias: 'h',
54
+ description: 'Print this usage guide.',
55
+ },
56
+ ],
57
+ },
58
+ ];
59
+ const usage = commandLineUsage(sections);
60
+ logger.info(usage);
61
+ process.exit(0);
62
+ }
63
+
64
+ module.exports = {
65
+ parseArgs,
66
+ showHelp,
67
+ };
package/config.js ADDED
@@ -0,0 +1,128 @@
1
+ const prompts = require('prompts');
2
+ const logger = require('./logger');
3
+ const path = require('path');
4
+ const { MAIN_APP_CONFIG, PROJECT_CONFIG } = require('./constants');
5
+ const { getUrlSafeFileName } = require('./utils');
6
+
7
+ async function getExtensionConfig(configuration, extension) {
8
+ if (extension && configuration[extension]) {
9
+ const { data } = configuration[extension];
10
+ return {
11
+ key: extension,
12
+ name: data.title,
13
+ file: data?.module?.file,
14
+ output: data?.output,
15
+ appName: data?.appName,
16
+ };
17
+ }
18
+
19
+ const extensionOptions = Object.keys(configuration);
20
+ const response = await prompts(
21
+ [
22
+ {
23
+ type: 'select',
24
+ name: 'extension',
25
+ message: 'Which extension would you like to run?',
26
+ choices: extensionOptions.map(option => {
27
+ const { data } = configuration[option];
28
+ return {
29
+ title: option,
30
+ value: {
31
+ key: option,
32
+ name: data?.title,
33
+ file: data?.module?.file,
34
+ output: data?.output,
35
+ appName: data?.appName,
36
+ },
37
+ };
38
+ }),
39
+ },
40
+ ],
41
+ {
42
+ onCancel: () => {
43
+ process.exit(0); // When the user cancels interaction, exit the script
44
+ },
45
+ }
46
+ );
47
+ return response.extension;
48
+ }
49
+
50
+ function _loadRequiredConfigFile(filePath) {
51
+ let config;
52
+ try {
53
+ config = require(filePath);
54
+ } catch (e) {
55
+ logger.error(
56
+ `Unable to load ${filePath} file. Please make sure you are running the command from the src/app/extensions directory and that ${filePath} exists`
57
+ );
58
+ process.exit(1);
59
+ }
60
+ return config;
61
+ }
62
+
63
+ function loadConfig() {
64
+ // app.json is one level up from the extensions directory, which is where these commands
65
+ // will need to be ran from, the extensions directory
66
+ const configPath = path.join(process.cwd(), `../${MAIN_APP_CONFIG}`);
67
+
68
+ const projectConfig = _loadRequiredConfigFile(
69
+ path.join(process.cwd(), `../../../${PROJECT_CONFIG}`)
70
+ );
71
+
72
+ const mainAppConfig = _loadRequiredConfigFile(configPath);
73
+
74
+ const crmCardsSubConfigFiles = mainAppConfig?.extensions?.crm?.cards;
75
+ if (!crmCardsSubConfigFiles) {
76
+ logger.error(
77
+ `The "extensions.crm.cards" array in ${configPath} is missing, it is a required configuration property`
78
+ );
79
+ process.exit(1);
80
+ } else if (crmCardsSubConfigFiles.length === 0) {
81
+ logger.error(
82
+ `The "extensions.crm.cards" array in ${configPath} is empty, it is a required configuration property.`
83
+ );
84
+ process.exit(1);
85
+ }
86
+
87
+ const outputConfig = {};
88
+
89
+ crmCardsSubConfigFiles.forEach(card => {
90
+ const extensionsRemoved = card.file.replace('extensions/', '');
91
+ const cardConfigPath = path.join(process.cwd(), extensionsRemoved);
92
+ // Get the path to the config file relative to the extensions directory
93
+ const configPathRelativeToExtensions = path.parse(extensionsRemoved)?.dir;
94
+
95
+ try {
96
+ const cardConfig = require(cardConfigPath);
97
+
98
+ // Join the two relative paths
99
+ const entryPointPath = path.join(
100
+ configPathRelativeToExtensions,
101
+ cardConfig.data?.module?.file
102
+ );
103
+
104
+ cardConfig.data.module.file = entryPointPath;
105
+
106
+ outputConfig[entryPointPath] = cardConfig;
107
+ outputConfig[entryPointPath].data.output = getUrlSafeFileName(
108
+ entryPointPath
109
+ );
110
+ outputConfig[entryPointPath].data.appName = projectConfig.name;
111
+ } catch (e) {
112
+ let errorMessage = e?.message;
113
+ if (e?.code === 'MODULE_NOT_FOUND') {
114
+ errorMessage = `Unable to load "${cardConfigPath}" file. \nPlease make sure you are running the command from the src/app/extensions directory and that your card JSON config exists within it.`;
115
+ }
116
+
117
+ logger.error(errorMessage);
118
+ process.exit(1);
119
+ }
120
+ });
121
+
122
+ return outputConfig;
123
+ }
124
+
125
+ module.exports = {
126
+ loadConfig,
127
+ getExtensionConfig,
128
+ };
package/constants.js ADDED
@@ -0,0 +1,25 @@
1
+ const VITE_DEFAULT_PORT = 5173;
2
+ const MAIN_APP_CONFIG = 'app.json';
3
+ const PROJECT_CONFIG = 'hsproject.json';
4
+ const OUTPUT_DIR = 'dist';
5
+
6
+ const ROLLUP_OPTIONS = {
7
+ // Deps to exclude from the bundle
8
+ external: ['react', 'react-dom', '@remote-ui/react'],
9
+ output: {
10
+ // Maps libs to the variables to be injected via the window
11
+ globals: {
12
+ react: 'React',
13
+ '@remote-ui/react': 'RemoteUI',
14
+ },
15
+ extend: true,
16
+ },
17
+ };
18
+
19
+ module.exports = {
20
+ VITE_DEFAULT_PORT,
21
+ ROLLUP_OPTIONS,
22
+ MAIN_APP_CONFIG,
23
+ PROJECT_CONFIG,
24
+ OUTPUT_DIR,
25
+ };
package/dev.js ADDED
@@ -0,0 +1,165 @@
1
+ const express = require('express');
2
+ const chokidar = require('chokidar');
3
+ const { WebSocketServer } = require('ws');
4
+ const http = require('http');
5
+ const logger = require('./logger');
6
+ const { build } = require('vite');
7
+ const { getExtensionConfig } = require('./config');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { ROLLUP_OPTIONS } = require('./constants');
11
+
12
+ async function _devBuild(config, outputDir, extension) {
13
+ const extensionConfig = await getExtensionConfig(config, extension);
14
+ build({
15
+ define: {
16
+ 'process.env.NODE_ENV': JSON.stringify(
17
+ process.env.NODE_ENV || 'development'
18
+ ),
19
+ },
20
+ build: {
21
+ watch: {
22
+ clearScreen: false,
23
+ exclude: [
24
+ 'node_modules',
25
+ 'package.json',
26
+ 'package-lock.json',
27
+ 'app.json',
28
+ ],
29
+ },
30
+ lib: {
31
+ entry: extensionConfig?.file,
32
+ name: extensionConfig?.output,
33
+ formats: ['iife'],
34
+ fileName: () => extensionConfig?.output,
35
+ },
36
+ rollupOptions: ROLLUP_OPTIONS,
37
+ outDir: outputDir,
38
+ emptyOutDir: true,
39
+ minify: false,
40
+ },
41
+ });
42
+ return extensionConfig;
43
+ }
44
+
45
+ function _startDevServer(outputDir, port, extensionConfig) {
46
+ const app = express();
47
+ const server = http.createServer(app);
48
+
49
+ // Host the OUTPUT_DIR
50
+ app.use(express.static(outputDir));
51
+
52
+ // Setup websocket server to send messages to browser on bundle update
53
+ const wss = new WebSocketServer({ server });
54
+
55
+ const callback = `http://localhost:${port}/${extensionConfig?.output}`;
56
+
57
+ function broadcast(message) {
58
+ if (wss.clients.size === 0) {
59
+ logger.warn('No browsers connected to update');
60
+ return;
61
+ }
62
+ wss.clients.forEach(client => {
63
+ client.send(JSON.stringify(message));
64
+ });
65
+ }
66
+
67
+ wss.on('connection', client => {
68
+ let base64Callback;
69
+ try {
70
+ base64Callback = fs
71
+ .readFileSync(
72
+ path.join(process.cwd(), outputDir, extensionConfig?.output)
73
+ )
74
+ .toString('base64');
75
+ } catch (e) {
76
+ logger.warn(
77
+ 'File not found:',
78
+ path.join(process.cwd(), outputDir, extensionConfig?.output)
79
+ );
80
+ }
81
+
82
+ logger.info('Browser connected and listening for bundle updates');
83
+ client.send(
84
+ JSON.stringify({
85
+ event: 'start',
86
+ appName: extensionConfig?.appName,
87
+ extension: extensionConfig?.name,
88
+ callback: base64Callback
89
+ ? `data:text/javascript;base64,${base64Callback}`
90
+ : undefined,
91
+ })
92
+ );
93
+ });
94
+
95
+ // Start the express and websocket servers
96
+ server.listen({ port }, () => {
97
+ logger.warn(`Listening at ${callback}`);
98
+ });
99
+
100
+ // Setup a watcher on the dist directory and update broadcast
101
+ //to all clients when an event is observed
102
+ chokidar.watch(outputDir).on('change', file => {
103
+ const base64Callback = fs
104
+ .readFileSync(path.join(process.cwd(), file))
105
+ .toString('base64');
106
+ logger.debug(`${file} updated, reloading extension`);
107
+ broadcast({
108
+ event: 'update',
109
+ appName: extensionConfig?.appName,
110
+ extension: extensionConfig?.name,
111
+ callback: `data:text/javascript;base64,${base64Callback}`,
112
+ });
113
+ });
114
+
115
+ _configureShutDownHandlers(wss, broadcast, extensionConfig, server);
116
+ }
117
+
118
+ function _configureShutDownHandlers(wss, broadcast, extensionConfig, server) {
119
+ function shutdown() {
120
+ logger.warn('\nSending shutdown signal to connected browser');
121
+ broadcast({
122
+ event: 'shutdown',
123
+ appName: extensionConfig?.appName,
124
+ extension: extensionConfig?.name,
125
+ });
126
+ logger.warn('\nCleaning up after ourselves...\n');
127
+
128
+ // Terminate all active connections, close seems to hang otherwise
129
+ wss.clients.forEach(client => {
130
+ client.terminate();
131
+ });
132
+
133
+ // Shut down the WebSocket server first since it is connected to the express server
134
+ wss.close(webSocketError => {
135
+ if (webSocketError) {
136
+ logger.error(
137
+ `WebSocket Server unable to shutdown correctly, ${webSocketError}`
138
+ );
139
+ } else {
140
+ logger.warn('WebSocket Server stopped');
141
+ }
142
+ // Shutdown the express server
143
+ server.close(error => {
144
+ if (error) {
145
+ logger.error(`Express server unable to shutdown correctly, ${error}`);
146
+ } else {
147
+ logger.warn('Express server stopped');
148
+ }
149
+ process.exit(webSocketError || error ? 1 : 0);
150
+ });
151
+ });
152
+ }
153
+
154
+ process.on('SIGINT', shutdown);
155
+ process.on('SIGTERM', shutdown);
156
+ }
157
+
158
+ async function startDevMode(config, outputDir, port, extension) {
159
+ const extensionConfig = await _devBuild(config, outputDir, extension);
160
+ _startDevServer(outputDir, port, extensionConfig);
161
+ }
162
+
163
+ module.exports = {
164
+ startDevMode,
165
+ };
package/index.js ADDED
@@ -0,0 +1,33 @@
1
+ const { OUTPUT_DIR } = require('./constants');
2
+ const { getUrlSafeFileName } = require('./utils');
3
+ const { buildSingleExtension } = require('./build');
4
+ const path = require('path');
5
+ const manifestPlugin = require('./plugins/manifestPlugin');
6
+
7
+ async function remoteBuild(root, entryPoint, outputDir) {
8
+ const allowedExtensions = ['.js', '.ts', '.tsx', '.jsx'];
9
+ const fileInfo = path.parse(entryPoint);
10
+
11
+ if (!allowedExtensions.includes(fileInfo.ext)) {
12
+ throw new Error(
13
+ `The last argument should be the filename you wish to build. Supported file extensions are [${allowedExtensions.join(
14
+ ', '
15
+ )}]`
16
+ );
17
+ }
18
+
19
+ await buildSingleExtension({
20
+ file: entryPoint,
21
+ outputFileName: getUrlSafeFileName(entryPoint),
22
+ outputDir: outputDir || OUTPUT_DIR,
23
+ plugins: {
24
+ rollup: [manifestPlugin({ minify: true })],
25
+ },
26
+ minify: true,
27
+ root,
28
+ });
29
+ }
30
+
31
+ module.exports = {
32
+ remoteBuild,
33
+ };
package/logger.js ADDED
@@ -0,0 +1,19 @@
1
+ const { cyan, red, yellow, magenta } = require('console-log-colors');
2
+
3
+ function info(message) {
4
+ console.log(cyan(message));
5
+ }
6
+
7
+ function error(message) {
8
+ console.error(red(message));
9
+ }
10
+
11
+ function warn(message) {
12
+ console.info(yellow(message));
13
+ }
14
+
15
+ function debug(message) {
16
+ console.debug(magenta(message));
17
+ }
18
+
19
+ module.exports = { info, error, warn, debug };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@hubspot/ui-extensions-dev-server",
3
+ "version": "0.0.1-beta.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo 'test'"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "README.md",
14
+ "build.js",
15
+ "cli.js",
16
+ "config.js",
17
+ "constants.js",
18
+ "dev.js",
19
+ "logger.js",
20
+ "run.js",
21
+ "utils.js",
22
+ "tests/runTests.js",
23
+ "tests/testBuild.js",
24
+ "tests/testDevServer.js",
25
+ "plugins/manifestPlugin.js",
26
+ "index.js"
27
+ ],
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "chokidar": "^3.5.3",
31
+ "command-line-args": "^5.2.1",
32
+ "command-line-usage": "^7.0.1",
33
+ "console-log-colors": "^0.4.0",
34
+ "express": "^4.18.2",
35
+ "process": "^0.11.10",
36
+ "prompts": "^2.4.2",
37
+ "vite": "^4.0.4",
38
+ "ws": "^8.12.1"
39
+ },
40
+ "bin": {
41
+ "hs-ui-extensions-dev-server": "run.js"
42
+ },
43
+ "eslintConfig": {
44
+ "env": {
45
+ "node": true
46
+ }
47
+ },
48
+ "engines": {
49
+ "node": ">=16"
50
+ },
51
+ "peerDependencies": {
52
+ "typescript": "^5.0.4"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "typescript": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "gitHead": "0bfb66cacc456a73f05a40841938e6b42f87aaba"
60
+ }
@@ -0,0 +1,108 @@
1
+ const { readFileSync } = require('fs');
2
+ const { normalize } = require('path');
3
+ const logger = require('../logger');
4
+
5
+ const DEFAULT_MANIFEST_NAME = 'manifest.json';
6
+ const PACKAGE_LOCK_FILE = 'package-lock.json';
7
+ const PACKAGE_FILE = 'package.json';
8
+ const EXTENSIONS_PATH = 'src/app/extensions/';
9
+
10
+ function plugin(options = {}) {
11
+ return {
12
+ name: 'ui-extensions-manifest-generation-plugin',
13
+ enforce: 'post', // run after default rollup plugins
14
+ generateBundle(_rollupOptions, bundle) {
15
+ const { output = DEFAULT_MANIFEST_NAME, minify = false } = options;
16
+ try {
17
+ const manifest = _generateManifestContents(bundle);
18
+ this.emitFile({
19
+ type: 'asset',
20
+ source: minify
21
+ ? JSON.stringify(manifest)
22
+ : JSON.stringify(manifest, null, 2),
23
+ fileName: normalize(output),
24
+ });
25
+ } catch (e) {
26
+ logger.warn(`\nUnable to write manifest file in ${output}, ${e}`);
27
+ }
28
+ },
29
+ };
30
+ }
31
+
32
+ function _generateManifestContents(bundle) {
33
+ const baseManifest = {
34
+ package: _loadPackageFile(),
35
+ };
36
+
37
+ // The keys to bundle are the filename without any path information
38
+ const bundles = Object.keys(bundle);
39
+
40
+ if (bundles.length === 1) {
41
+ return {
42
+ ..._generateManifestEntry(bundle[bundles[0]]),
43
+ ...baseManifest,
44
+ };
45
+ }
46
+
47
+ const manifest = bundles.reduce((acc, current) => {
48
+ return {
49
+ ...acc,
50
+ [current]: _generateManifestEntry(bundle[current], false),
51
+ };
52
+ }, {});
53
+
54
+ return {
55
+ ...manifest,
56
+ ...baseManifest,
57
+ };
58
+ }
59
+
60
+ function _generateManifestEntry(subBundle) {
61
+ const { facadeModuleId, moduleIds, modules } = subBundle;
62
+ return {
63
+ entry: _stripPathPriorToExtDir(facadeModuleId),
64
+ modules: _buildModulesInfo(moduleIds, modules),
65
+ };
66
+ }
67
+
68
+ function _loadJsonFileSafely(filename) {
69
+ try {
70
+ return JSON.parse(readFileSync(filename).toString());
71
+ } catch (e) {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function _loadPackageFile() {
77
+ // Look for package-lock.json then fallback to package.json
78
+ return (
79
+ _loadJsonFileSafely(PACKAGE_LOCK_FILE) || _loadJsonFileSafely(PACKAGE_FILE)
80
+ );
81
+ }
82
+
83
+ function _stripPathPriorToExtDir(filepath) {
84
+ return filepath.split(EXTENSIONS_PATH).pop();
85
+ }
86
+
87
+ function _buildModulesInfo(moduleIds, modules) {
88
+ return moduleIds.reduce(
89
+ (acc, mod) => {
90
+ const { renderedExports } = modules[mod];
91
+
92
+ const moduleData = {
93
+ module: _stripPathPriorToExtDir(mod),
94
+ renderedExports,
95
+ };
96
+
97
+ if (moduleData.module.includes('node_modules')) {
98
+ acc.external.push(moduleData);
99
+ } else {
100
+ acc.internal.push(moduleData);
101
+ }
102
+ return acc;
103
+ },
104
+ { internal: [], external: [] }
105
+ );
106
+ }
107
+
108
+ module.exports = plugin;
package/run.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { startDevMode } = require('./dev');
4
+ const { loadConfig } = require('./config');
5
+ const { buildAllExtensions, buildSingleExtension } = require('./build');
6
+ const { VITE_DEFAULT_PORT, OUTPUT_DIR } = require('./constants');
7
+ const { parseArgs, showHelp } = require('./cli');
8
+ const { getUrlSafeFileName } = require('./utils');
9
+ const manifestPlugin = require('./plugins/manifestPlugin');
10
+
11
+ const { DEV_MODE, BUILD_MODE, port, extension, help } = parseArgs();
12
+ const PORT = port || VITE_DEFAULT_PORT;
13
+
14
+ if (help || !(DEV_MODE || BUILD_MODE)) {
15
+ showHelp(OUTPUT_DIR);
16
+ }
17
+
18
+ if (DEV_MODE) {
19
+ startDevMode(loadConfig(), OUTPUT_DIR, PORT, extension);
20
+ } else if (BUILD_MODE) {
21
+ if (extension) {
22
+ buildSingleExtension({
23
+ file: extension,
24
+ outputFileName: getUrlSafeFileName(extension),
25
+ outputDir: OUTPUT_DIR,
26
+ plugins: { rollup: [manifestPlugin()] },
27
+ });
28
+ } else {
29
+ buildAllExtensions(loadConfig(), OUTPUT_DIR);
30
+ }
31
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { testBuild } = require('./testBuild');
4
+ const { testDevServer } = require('./testDevServer');
5
+ const logger = require('../logger');
6
+
7
+ let devServerProcess;
8
+
9
+ function handleFailure() {
10
+ if (devServerProcess) {
11
+ devServerProcess.kill();
12
+ }
13
+ }
14
+
15
+ process.on('SIGINT', () => {
16
+ handleFailure();
17
+ });
18
+
19
+ process.on('uncaughtException', () => {
20
+ handleFailure();
21
+ });
22
+
23
+ try {
24
+ testBuild(logger);
25
+ testDevServer(logger, devServerProcess);
26
+ } catch (e) {
27
+ console.error(e.message);
28
+ logger.error('Tests failed 😭');
29
+ handleFailure();
30
+ process.exit(1);
31
+ }
@@ -0,0 +1,93 @@
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const assert = require('assert');
5
+
6
+ function _testHelper(command, outputDirFiles) {
7
+ if (command) {
8
+ execSync(command);
9
+ }
10
+
11
+ // Make sure the files are getting generated in the dist dir
12
+ const distDir = path.join(process.cwd(), 'dist');
13
+ const filesInOutputDir = fs.readdirSync(distDir);
14
+ assert.deepStrictEqual(filesInOutputDir, outputDirFiles);
15
+
16
+ // Spot check the file contents to make sure they seem ok
17
+ filesInOutputDir.forEach(file => {
18
+ const fileContents = fs.readFileSync(path.join(distDir, file)).toString();
19
+ if (file === 'manifest.json') {
20
+ const manifest = JSON.parse(fileContents);
21
+ assert(manifest.entry);
22
+ assert(manifest.modules);
23
+ assert(manifest.modules.internal);
24
+ manifest.modules.internal.forEach(mod => {
25
+ assert(mod.module);
26
+ assert(mod.renderedExports);
27
+ });
28
+ assert(manifest.modules.external);
29
+ manifest.modules.external.forEach(mod => {
30
+ assert(mod.module);
31
+ assert(mod.renderedExports);
32
+ });
33
+ assert(manifest.package);
34
+ assert(manifest.package.packages);
35
+ } else {
36
+ const stringsToSpotCheck = [
37
+ '.createRemoteReactComponent',
38
+ '.createElement',
39
+ 'hubspot.extend',
40
+ 'React',
41
+ 'RemoteUI',
42
+ ];
43
+ stringsToSpotCheck.forEach(stringToCheck => {
44
+ assert(
45
+ fileContents.includes(stringToCheck),
46
+ `File ${file} contents should contain: "${stringToCheck}"`
47
+ );
48
+ });
49
+ }
50
+ });
51
+ }
52
+
53
+ function testDefaultBuildPath(logger) {
54
+ logger.warn('- Test default build path started 🤞');
55
+ _testHelper('hs-ui-extensions-dev-server build', [
56
+ 'PhoneLines.js',
57
+ 'ProgressBarApp.js',
58
+ ]);
59
+ logger.info('- Test default build path passed 🚀');
60
+ }
61
+
62
+ function testBuildWithExtensionFlag(logger) {
63
+ logger.warn('- Test build with flags started 🤞');
64
+ _testHelper(
65
+ 'hs-ui-extensions-dev-server build --extension ProgressBarApp.tsx',
66
+ ['ProgressBarApp.js', 'manifest.json']
67
+ );
68
+ logger.info('- Test build with flags passed 🚀');
69
+ }
70
+
71
+ function testDefInfraBuildFileName(logger) {
72
+ const { remoteBuild } = require('../index');
73
+ logger.warn('- Test remoteBuild function 🤞');
74
+
75
+ remoteBuild(process.cwd(), 'ProgressBarApp.tsx', 'dist');
76
+ _testHelper(null, ['ProgressBarApp.js', 'manifest.json']);
77
+ logger.info('- Test build with entrypoint as arg 🚀');
78
+ }
79
+
80
+ function testBuild(logger) {
81
+ logger.warn('\nBuild Tests started - External Devs 🤞');
82
+ testDefaultBuildPath(logger);
83
+ testBuildWithExtensionFlag(logger);
84
+ logger.info('Build Tests passed - External Devs🚀');
85
+
86
+ logger.warn('\nBuild Tests started - Dev Infra 🤞');
87
+ testDefInfraBuildFileName(logger);
88
+ logger.info('Build Tests passed - Dev Infra 🚀');
89
+ }
90
+
91
+ module.exports = {
92
+ testBuild,
93
+ };
@@ -0,0 +1,149 @@
1
+ const { spawn } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const assert = require('assert');
5
+ const http = require('http');
6
+ const WebSocket = require('ws');
7
+
8
+ const testResults = {
9
+ buildTestPassed: false,
10
+ expressTestPassed: false,
11
+ webSocketTestPassed: false,
12
+ };
13
+
14
+ const port = 5172;
15
+
16
+ function testDevServer(logger, devServerProcess) {
17
+ logger.warn('\nDev Server Tests started 🤞');
18
+
19
+ // We need to use spawn here because it will put the process into the background,
20
+ // which is required because dev mode is a blocking process and we want to test that
21
+ // the express server and websocket server are starting properly
22
+ devServerProcess = spawn('hs-ui-extensions-dev-server', [
23
+ 'dev',
24
+ '--extension',
25
+ 'PhoneLines.tsx',
26
+ '--port',
27
+ `${port}`,
28
+ ]);
29
+
30
+ devServerProcess.stdout.on('data', buffer => {
31
+ const data = buffer.toString().toLowerCase();
32
+ console.log('[Dev Server Log]:', data);
33
+ if (data.includes('built in')) {
34
+ testBuild(testResults, logger);
35
+ }
36
+ if (data.includes('listening') && data.includes(`localhost:${port}`)) {
37
+ setTimeout(() => {
38
+ testExpressServer(testResults, logger);
39
+ testWebSocketServer(testResults, logger);
40
+ }, 1000);
41
+ }
42
+ });
43
+
44
+ // If the dev server writes to stderr, log the error and throw a new error
45
+ devServerProcess.stderr.on('data', buffer => {
46
+ const data = buffer.toString();
47
+ logger.error(data.toString());
48
+ throw new Error(data);
49
+ });
50
+
51
+ // When the process closes make sure we met all the success conditions
52
+ devServerProcess.on('close', () => {
53
+ if (metConditions()) {
54
+ logger.info('Dev Server Tests passed 🚀');
55
+ } else {
56
+ console.log(testResults);
57
+ logger.error('Tests failed 😭');
58
+ }
59
+ });
60
+
61
+ const interval = setInterval(callback, 1000);
62
+ let count = 0;
63
+ function callback() {
64
+ count += 1;
65
+ if (metConditions() || count === 5) {
66
+ devServerProcess.kill();
67
+ clearInterval(interval);
68
+ }
69
+ }
70
+ }
71
+
72
+ function metConditions() {
73
+ const {
74
+ buildTestPassed,
75
+ expressTestPassed,
76
+ webSocketTestPassed,
77
+ } = testResults;
78
+ return buildTestPassed && expressTestPassed && webSocketTestPassed;
79
+ }
80
+
81
+ // Test that the files were built in the proper location and spot
82
+ // check the contents of the files.
83
+ function testBuild(results, logger) {
84
+ // // Make sure the files are getting generated in the dist dir
85
+ const distDir = path.join(process.cwd(), 'dist');
86
+ const filesInOutputDir = fs.readdirSync(distDir);
87
+ assert.deepStrictEqual(filesInOutputDir, ['PhoneLines.js']);
88
+ const fileContents = fs
89
+ .readFileSync(path.join(distDir, filesInOutputDir[0]))
90
+ .toString();
91
+ const stringsToSpotCheck = [
92
+ '.createRemoteReactComponent',
93
+ '.createElement',
94
+ 'hubspot.extend',
95
+ 'React',
96
+ 'RemoteUI',
97
+ ];
98
+ stringsToSpotCheck.forEach(stringToCheck => {
99
+ assert(
100
+ fileContents.includes(stringToCheck),
101
+ `File ${filesInOutputDir[0]} contents should contain: "${stringToCheck}"`
102
+ );
103
+ });
104
+ logger.info('- Build succeeded 🚀');
105
+ results.buildTestPassed = true;
106
+ }
107
+
108
+ // Test that the express server is running on the expected port
109
+ // and that it is serving the files as expected.
110
+ function testExpressServer(results, logger) {
111
+ http.get(
112
+ {
113
+ host: 'localhost',
114
+ port,
115
+ path: '/PhoneLines.js',
116
+ },
117
+ response => {
118
+ if (response.statusCode !== 200) {
119
+ throw Error('Error with express server');
120
+ }
121
+ logger.info('- Express server connected and serving files 🚀');
122
+ results.expressTestPassed = true;
123
+ }
124
+ );
125
+ }
126
+
127
+ // Test the the web socket server is running on the expected port and
128
+ // that we are able to receive messages from it.
129
+ function testWebSocketServer(results, logger) {
130
+ const fileContents = fs
131
+ .readFileSync(path.join('dist', 'PhoneLines.js'))
132
+ .toString('base64');
133
+
134
+ const ws = new WebSocket(`ws://localhost:${port}`);
135
+ ws.on('message', messageBuffer => {
136
+ const message = JSON.parse(messageBuffer.toString());
137
+ console.log('[WebSocket Message]:', message);
138
+ assert(message.event === 'start' || message.event === 'update');
139
+ assert(message.appName === 'example-app-remote-ui');
140
+ assert.strictEqual(message.extension, 'Phone Lines');
141
+ assert(message.callback, `data:text/javascript;base64,${fileContents}`);
142
+ logger.info('- WebSocket server connected and sending messages 🚀');
143
+ results.webSocketTestPassed = true;
144
+ });
145
+ }
146
+
147
+ module.exports = {
148
+ testDevServer,
149
+ };
package/utils.js ADDED
@@ -0,0 +1,10 @@
1
+ const path = require('path');
2
+
3
+ function getUrlSafeFileName(filePath) {
4
+ const { name } = path.parse(filePath);
5
+ return encodeURIComponent(`${name}.js`);
6
+ }
7
+
8
+ module.exports = {
9
+ getUrlSafeFileName,
10
+ };