@hubspot/ui-extensions-dev-server 0.0.1-prealpha.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,15 @@
1
+ # ui-extensions-dev-server
2
+ This package contains the build script for bundling and serving remote extensions locally. It checks for a config file named `extension-config.json` in directory the dev server command is ran from. This config file is optional, and the below example contains what the default values are.
3
+
4
+ Example config file:
5
+
6
+ ```json
7
+ {
8
+ "version": "1.0",
9
+ "extensions": {
10
+ "example-app": {
11
+ "file": "App.tsx"
12
+ }
13
+ }
14
+ }
15
+ ```
package/build.js ADDED
@@ -0,0 +1,32 @@
1
+ /* eslint-disable hubspot-dev/no-async-await */
2
+ /*global module*/
3
+ 'use es6';
4
+
5
+ const { build } = require('vite');
6
+ const { ROLLUP_OPTIONS } = require('./constants');
7
+
8
+ async function buildExtensions(config, outputDir) {
9
+ const extensionKeys = Object.keys(config);
10
+
11
+ for (let i = 0; i < extensionKeys.length; ++i) {
12
+ const { data } = config[extensionKeys[i]];
13
+ await build({
14
+ build: {
15
+ lib: {
16
+ entry: data?.module?.file,
17
+ name: data.output,
18
+ formats: ['iife'],
19
+ fileName: () => data.output,
20
+ },
21
+ rollupOptions: ROLLUP_OPTIONS,
22
+ outDir: outputDir,
23
+ emptyOutDir: i === 0,
24
+ minify: false,
25
+ },
26
+ });
27
+ }
28
+ }
29
+
30
+ module.exports = {
31
+ buildExtensions,
32
+ };
package/config.js ADDED
@@ -0,0 +1,107 @@
1
+ /* eslint-disable hubspot-dev/no-async-await */
2
+ /*global process, module */
3
+ 'use es6';
4
+ const prompts = require('prompts');
5
+ const logger = require('./logger');
6
+ const path = require('path');
7
+ const { MAIN_APP_CONFIG, PROJECT_CONFIG } = require('./constants');
8
+ const { getUrlSafeFileName } = require('./utils');
9
+
10
+ async function getExtensionConfig(configuration) {
11
+ if (process.env.EXTENSION && configuration[process.env.EXTENSION]) {
12
+ const { data } = configuration[process.env.EXTENSION];
13
+ return {
14
+ key: process.env.EXTENSION,
15
+ name: data.title,
16
+ file: data?.module?.file,
17
+ output: data?.output,
18
+ appName: data?.appName,
19
+ };
20
+ }
21
+
22
+ const extensionOptions = Object.keys(configuration);
23
+ const response = await prompts(
24
+ [
25
+ {
26
+ type: 'select',
27
+ name: 'extension',
28
+ message: 'Which extension would you like to run?',
29
+ choices: extensionOptions.map(option => {
30
+ const { data } = configuration[option];
31
+ return {
32
+ title: option,
33
+ value: {
34
+ key: option,
35
+ name: data?.title,
36
+ file: data?.module?.file,
37
+ output: data?.output,
38
+ appName: data?.appName,
39
+ },
40
+ };
41
+ }),
42
+ },
43
+ ],
44
+ {
45
+ onCancel: () => {
46
+ process.exit(0); // When the user cancels interaction, exit the script
47
+ },
48
+ }
49
+ );
50
+ return response.extension;
51
+ }
52
+
53
+ function _loadRequiredConfigFile(filePath) {
54
+ let config;
55
+ try {
56
+ config = require(filePath);
57
+ } catch (e) {
58
+ logger.error(
59
+ `Unable to load ${filePath} file. Please make sure you are running the command from the src/app/extensions directory and that ${filePath} exists`
60
+ );
61
+ process.exit(1);
62
+ }
63
+ return config;
64
+ }
65
+
66
+ function loadConfig() {
67
+ // app.json is one level up from the extensions directory, which is where these commands
68
+ // will need to be ran from, the extensions directory
69
+ const configPath = path.join(process.cwd(), `../${MAIN_APP_CONFIG}`);
70
+
71
+ const projectConfig = _loadRequiredConfigFile(
72
+ path.join(process.cwd(), `../../../${PROJECT_CONFIG}`)
73
+ );
74
+
75
+ const mainAppConfig = _loadRequiredConfigFile(configPath);
76
+
77
+ if (!mainAppConfig?.extensions?.crm?.cards) {
78
+ logger.error(
79
+ `"extensions.crm.cards" is missing in ${configPath}, it is a required configuration property`
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ const outputConfig = {};
85
+ try {
86
+ mainAppConfig.extensions.crm.cards.forEach(card => {
87
+ const extensionsRemoved = card.file.replace('extensions/', '');
88
+ const cardConfig = require(path.join(process.cwd(), extensionsRemoved));
89
+ const extensionFileName = cardConfig.data?.module?.file;
90
+ outputConfig[extensionFileName] = cardConfig;
91
+ outputConfig[extensionFileName].data.output = getUrlSafeFileName(
92
+ cardConfig.data?.module?.file
93
+ );
94
+ outputConfig[extensionFileName].data.appName = projectConfig.name;
95
+ });
96
+ } catch (e) {
97
+ logger.error(e.message);
98
+ process.exit(1);
99
+ }
100
+
101
+ return outputConfig;
102
+ }
103
+
104
+ module.exports = {
105
+ loadConfig,
106
+ getExtensionConfig,
107
+ };
package/constants.js ADDED
@@ -0,0 +1,26 @@
1
+ /*global module */
2
+ 'use es6';
3
+
4
+ const VITE_DEFAULT_PORT = 5173;
5
+ const MAIN_APP_CONFIG = 'app.json';
6
+ const PROJECT_CONFIG = 'hsproject.json';
7
+
8
+ const ROLLUP_OPTIONS = {
9
+ // Deps to exclude from the bundle
10
+ external: ['react', 'react-dom', '@remote-ui/react'],
11
+ output: {
12
+ // Maps libs to the variables to be injected via the window
13
+ globals: {
14
+ react: 'React',
15
+ '@remote-ui/react': 'RemoteUI',
16
+ },
17
+ extend: true,
18
+ },
19
+ };
20
+
21
+ module.exports = {
22
+ VITE_DEFAULT_PORT,
23
+ ROLLUP_OPTIONS,
24
+ MAIN_APP_CONFIG,
25
+ PROJECT_CONFIG,
26
+ };
package/dev.js ADDED
@@ -0,0 +1,131 @@
1
+ /* eslint-disable hubspot-dev/no-async-await */
2
+ /*global module, process*/
3
+ 'use es6';
4
+ const express = require('express');
5
+ const chokidar = require('chokidar');
6
+ const { WebSocketServer } = require('ws');
7
+ const http = require('http');
8
+ const logger = require('./logger');
9
+ const { build } = require('vite');
10
+ const { getExtensionConfig } = require('./config');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { ROLLUP_OPTIONS } = require('./constants');
14
+
15
+ async function _devBuild(config, outputDir) {
16
+ const extensionConfig = await getExtensionConfig(config);
17
+ build({
18
+ build: {
19
+ watch: {
20
+ clearScreen: false,
21
+ exclude: [
22
+ 'node_modules',
23
+ 'package.json',
24
+ 'package-lock.json',
25
+ 'app.json',
26
+ ],
27
+ },
28
+ lib: {
29
+ entry: extensionConfig?.file,
30
+ name: extensionConfig?.output,
31
+ formats: ['iife'],
32
+ fileName: () => extensionConfig?.output,
33
+ },
34
+ rollupOptions: ROLLUP_OPTIONS,
35
+ outDir: outputDir,
36
+ emptyOutDir: true,
37
+ minify: false,
38
+ },
39
+ });
40
+ return extensionConfig;
41
+ }
42
+
43
+ function _startDevServer(outputDir, port, extensionConfig) {
44
+ const app = express();
45
+ const server = http.createServer(app);
46
+
47
+ // Host the OUTPUT_DIR
48
+ app.use(express.static(outputDir));
49
+
50
+ // Setup websocket server to send messages to browser on bundle update
51
+ const wss = new WebSocketServer({ server });
52
+
53
+ const callback = `http://localhost:${port}/${extensionConfig?.output}`;
54
+
55
+ function broadcast(message) {
56
+ if (wss.clients.size === 0) {
57
+ logger.warn('No browsers connected to update');
58
+ return;
59
+ }
60
+ wss.clients.forEach(client => {
61
+ client.send(JSON.stringify(message));
62
+ });
63
+ }
64
+
65
+ wss.on('connection', client => {
66
+ let base64Callback;
67
+ try {
68
+ base64Callback = fs
69
+ .readFileSync(
70
+ path.join(process.cwd(), outputDir, extensionConfig?.output)
71
+ )
72
+ .toString('base64');
73
+ } catch (e) {
74
+ logger.warn(
75
+ 'File not found:',
76
+ path.join(process.cwd(), outputDir, extensionConfig?.output)
77
+ );
78
+ }
79
+
80
+ logger.info('Browser connected and listening for bundle updates');
81
+ client.send(
82
+ JSON.stringify({
83
+ event: 'start',
84
+ appName: extensionConfig?.appName,
85
+ extension: extensionConfig?.name,
86
+ callback: base64Callback
87
+ ? `data:text/javascript;base64,${base64Callback}`
88
+ : undefined,
89
+ })
90
+ );
91
+ });
92
+
93
+ // Start the express and websocket servers
94
+ server.listen({ port }, () => {
95
+ logger.warn(`Listening at ${callback}`);
96
+ });
97
+
98
+ // Setup a watcher on the dist directory and update broadcast
99
+ //to all clients when an event is observed
100
+ chokidar.watch(outputDir).on('change', file => {
101
+ const base64Callback = fs
102
+ .readFileSync(path.join(process.cwd(), file))
103
+ .toString('base64');
104
+ logger.debug(`${file} updated, reloading extension`);
105
+ broadcast({
106
+ event: 'update',
107
+ appName: extensionConfig?.appName,
108
+ extension: extensionConfig?.name,
109
+ callback: `data:text/javascript;base64,${base64Callback}`,
110
+ });
111
+ });
112
+
113
+ process.on('SIGINT', () => {
114
+ logger.warn('\nSending shutdown signal to connected browser');
115
+ broadcast({
116
+ event: 'shutdown',
117
+ appName: extensionConfig?.appName,
118
+ extension: extensionConfig?.name,
119
+ });
120
+ process.exit(0);
121
+ });
122
+ }
123
+
124
+ async function startDevMode(config, outputDir, port) {
125
+ const extensionConfig = await _devBuild(config, outputDir);
126
+ _startDevServer(outputDir, port, extensionConfig);
127
+ }
128
+
129
+ module.exports = {
130
+ startDevMode,
131
+ };
package/logger.js ADDED
@@ -0,0 +1,22 @@
1
+ /*global module*/
2
+ 'use es6';
3
+
4
+ const { cyan, red, yellow, magenta } = require('console-log-colors');
5
+
6
+ function info(message) {
7
+ console.log(cyan(message));
8
+ }
9
+
10
+ function error(message) {
11
+ console.error(red(message));
12
+ }
13
+
14
+ function warn(message) {
15
+ console.info(yellow(message));
16
+ }
17
+
18
+ function debug(message) {
19
+ console.debug(magenta(message));
20
+ }
21
+
22
+ module.exports = { info, error, warn, debug };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@hubspot/ui-extensions-dev-server",
3
+ "version": "0.0.1-prealpha.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo 'test'"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "chokidar": "^3.5.3",
15
+ "console-log-colors": "^0.4.0",
16
+ "express": "^4.18.2",
17
+ "process": "^0.11.10",
18
+ "prompts": "^2.4.2",
19
+ "vite": "^4.0.4",
20
+ "ws": "^8.12.1"
21
+ },
22
+ "bin": {
23
+ "hs-ui-extensions-dev-server": "run.js"
24
+ },
25
+ "gitHead": "eb6b25c4f33ce83c1727cdddb7024267f0b63a70"
26
+ }
package/run.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable hubspot-dev/no-async-await */
3
+ /*global process*/
4
+ 'use es6';
5
+
6
+ const { startDevMode } = require('./dev');
7
+ const { loadConfig } = require('./config');
8
+ const { buildExtensions } = require('./build');
9
+ const { VITE_DEFAULT_PORT } = require('./constants');
10
+
11
+ const DEV_MODE = process.env.DEV_MODE || false;
12
+ const OUTPUT_DIR = 'dist';
13
+ const PORT = process.env.PORT || VITE_DEFAULT_PORT;
14
+
15
+ const config = loadConfig();
16
+
17
+ if (DEV_MODE) {
18
+ startDevMode(config, OUTPUT_DIR, PORT);
19
+ } else {
20
+ buildExtensions(config, OUTPUT_DIR);
21
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /*global process */
3
+ 'use es6';
4
+
5
+ const { testBuild } = require('./testBuild');
6
+ const { testDevServer } = require('./testDevServer');
7
+ const logger = require('../logger');
8
+
9
+ let devServerProcess;
10
+
11
+ function handleFailure() {
12
+ if (devServerProcess) {
13
+ devServerProcess.kill();
14
+ }
15
+ }
16
+
17
+ process.on('SIGINT', () => {
18
+ handleFailure();
19
+ });
20
+
21
+ process.on('uncaughtException', () => {
22
+ handleFailure();
23
+ });
24
+
25
+ try {
26
+ testBuild(logger);
27
+ testDevServer(logger, devServerProcess);
28
+ } catch (e) {
29
+ console.error(e.message);
30
+ logger.error('Tests failed 😭');
31
+ handleFailure();
32
+ process.exit(1);
33
+ }
@@ -0,0 +1,44 @@
1
+ /*global module, process */
2
+ 'use es6';
3
+
4
+ const { execSync } = require('child_process');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const assert = require('assert');
8
+
9
+ function testBuild(logger) {
10
+ logger.info('Build Tests started 🤞');
11
+ execSync('hs-ui-extensions-dev-server');
12
+
13
+ // Make sure the files are getting generated in the dist dir
14
+ const distDir = path.join(process.cwd(), 'dist');
15
+ const filesInOutputDir = fs.readdirSync(distDir);
16
+ assert.deepStrictEqual(filesInOutputDir, [
17
+ 'PhoneLines.js',
18
+ 'ProgressBarApp.js',
19
+ ]);
20
+
21
+ // Spot check the file contents to make sure they seem ok
22
+ filesInOutputDir.forEach(file => {
23
+ const fileContents = fs.readFileSync(path.join(distDir, file)).toString();
24
+ const stringsToSpotCheck = [
25
+ '.createRemoteReactComponent',
26
+ '.createElement',
27
+ 'hubspot.extend',
28
+ 'React',
29
+ 'RemoteUI',
30
+ ];
31
+ stringsToSpotCheck.forEach(stringToCheck => {
32
+ assert(
33
+ fileContents.includes(stringToCheck),
34
+ `File ${file} contents should contain: "${stringToCheck}"`
35
+ );
36
+ });
37
+ });
38
+
39
+ logger.info('Build Tests passed 🚀');
40
+ }
41
+
42
+ module.exports = {
43
+ testBuild,
44
+ };
@@ -0,0 +1,139 @@
1
+ /*global module, process */
2
+ 'use es6';
3
+
4
+ const { spawn } = require('child_process');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const assert = require('assert');
8
+ const http = require('http');
9
+ const WebSocket = require('ws');
10
+
11
+ const testResults = {
12
+ buildTestPassed: false,
13
+ expressTestPassed: false,
14
+ webSocketTestPassed: false,
15
+ };
16
+
17
+ function testDevServer(logger, devServerProcess) {
18
+ logger.info('Dev Server Tests started 🤞');
19
+
20
+ // We need to use spawn here because it will put the process into the background,
21
+ // which is required because dev mode is a blocking process and we want to test that
22
+ // the express server and websocket server are starting properly
23
+ devServerProcess = spawn('hs-ui-extensions-dev-server', {
24
+ env: { ...process.env, DEV_MODE: 'true', EXTENSION: 'PhoneLines.tsx' },
25
+ });
26
+
27
+ devServerProcess.stdout.on('data', buffer => {
28
+ const data = buffer.toString().toLowerCase();
29
+ if (data.includes('built in')) {
30
+ testBuild(testResults);
31
+ }
32
+ if (data.includes('listening') && data.includes('localhost:5173')) {
33
+ testExpressServer(testResults);
34
+ testWebSocketServer(testResults);
35
+ }
36
+ });
37
+
38
+ // If the dev server writes to stderr, log the error and throw a new error
39
+ devServerProcess.stderr.on('data', buffer => {
40
+ const data = buffer.toString();
41
+ logger.error(data.toString());
42
+ throw new Error(data);
43
+ });
44
+
45
+ // When the process closes make sure we met all the success conditions
46
+ devServerProcess.on('close', () => {
47
+ if (metConditions()) {
48
+ logger.info('Dev Server Tests passed 🚀');
49
+ } else {
50
+ console.log(testResults);
51
+ logger.error('Tests failed 😭');
52
+ }
53
+ });
54
+
55
+ const interval = setInterval(callback, 1000);
56
+ let count = 0;
57
+ function callback() {
58
+ count += 1;
59
+ if (metConditions() || count === 5) {
60
+ devServerProcess.kill();
61
+ clearInterval(interval);
62
+ }
63
+ }
64
+ }
65
+
66
+ function metConditions() {
67
+ const {
68
+ buildTestPassed,
69
+ expressTestPassed,
70
+ webSocketTestPassed,
71
+ } = testResults;
72
+ return buildTestPassed && expressTestPassed && webSocketTestPassed;
73
+ }
74
+
75
+ // Test that the files were built in the proper location and spot
76
+ // check the contents of the files.
77
+ function testBuild(results) {
78
+ // // Make sure the files are getting generated in the dist dir
79
+ const distDir = path.join(process.cwd(), 'dist');
80
+ const filesInOutputDir = fs.readdirSync(distDir);
81
+ assert.deepStrictEqual(filesInOutputDir, ['PhoneLines.js']);
82
+ const fileContents = fs
83
+ .readFileSync(path.join(distDir, filesInOutputDir[0]))
84
+ .toString();
85
+ const stringsToSpotCheck = [
86
+ '.createRemoteReactComponent',
87
+ '.createElement',
88
+ 'hubspot.extend',
89
+ 'React',
90
+ 'RemoteUI',
91
+ ];
92
+ stringsToSpotCheck.forEach(stringToCheck => {
93
+ assert(
94
+ fileContents.includes(stringToCheck),
95
+ `File ${filesInOutputDir[0]} contents should contain: "${stringToCheck}"`
96
+ );
97
+ });
98
+ results.buildTestPassed = true;
99
+ }
100
+
101
+ // Test that the express server is running on the expected port
102
+ // and that it is serving the files as expected.
103
+ function testExpressServer(results) {
104
+ http.get(
105
+ {
106
+ host: 'localhost',
107
+ port: 5173,
108
+ path: '/PhoneLines.js',
109
+ },
110
+ response => {
111
+ if (response.statusCode !== 200) {
112
+ throw Error('Error with express server');
113
+ }
114
+ results.expressTestPassed = true;
115
+ }
116
+ );
117
+ }
118
+
119
+ // Test the the web socket server is running on the expected port and
120
+ // that we are able to recieve messages from it.
121
+ function testWebSocketServer(results) {
122
+ const fileContents = fs
123
+ .readFileSync(path.join('dist', 'PhoneLines.js'))
124
+ .toString('base64');
125
+
126
+ const ws = new WebSocket('ws://localhost:5173');
127
+ ws.on('message', messageBuffer => {
128
+ const message = JSON.parse(messageBuffer.toString());
129
+ assert(message.event === 'start' || message.event === 'update');
130
+ assert(message.appName === 'example-app');
131
+ assert.strictEqual(message.extension, 'Phone Lines');
132
+ assert(message.callback, `data:text/javascript;base64,${fileContents}`);
133
+ results.webSocketTestPassed = true;
134
+ });
135
+ }
136
+
137
+ module.exports = {
138
+ testDevServer,
139
+ };
package/utils.js ADDED
@@ -0,0 +1,12 @@
1
+ /*global module*/
2
+ 'use es6';
3
+
4
+ function getUrlSafeFileName(filePath) {
5
+ const fileName = filePath.split('/').pop();
6
+ const fileNameWithJsExtension = fileName.replace(/\.ts[x]?$|\.jsx$/g, '.js');
7
+ return encodeURIComponent(fileNameWithJsExtension);
8
+ }
9
+
10
+ module.exports = {
11
+ getUrlSafeFileName,
12
+ };