From 4a9548c38a4f91eda84fcb051abdc470d82fab61 Mon Sep 17 00:00:00 2001 From: Ray Lee Date: Fri, 19 Apr 2024 23:31:56 -0400 Subject: [PATCH] Add support for devserver --back-end option. --- README.md | 23 ++- index.html | 2 +- webpack.config.js | 27 ++-- webpackDevServerConfig.js | 321 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 webpackDevServerConfig.js diff --git a/README.md b/README.md index 7e94c00..65589f8 100644 --- a/README.md +++ b/README.md @@ -19,24 +19,23 @@ $ cd cspace-ui-plugin-profile-materials.js $ npm install ``` -To run the cspace-ui application configured with this plugin: +To run the cspace-ui application configured with this plugin in development, using a remote +back-end CollectionSpace server: ``` -$ npm run devserver +$ npm run devserver --back-end=https://materials.dev.collectionspace.org ``` Then open a browser to http://localhost:8080. -By default, the application served from the dev server will use the CollectionSpace services API -located at http://localhost:8180. - -To run the application against CollectionSpace services located on a different host, edit -index.html, and change the `serverUrl` configuration property. For example, to use a server running -on nightly.collectionspace.org, port 8180, use the settings: +Alternatively, to run the cspace-ui application configured with this plugin in development, using +the UI configuration in index.html: ``` -cspaceUI({ - serverUrl: 'http://nightly.collectionspace.org:8180', - // ... -}); +$ npm run devserver ``` + +By default, the configuration in index.html uses the CollectionSpace services API located at +http://localhost:8180. To run the application against CollectionSpace services located on a +different host, edit index.html, and change the `serverUrl` configuration property. Note that the +specified server must be configured to allow CORS requests from http://localhost:8080. diff --git a/index.html b/index.html index 6490d32..aa34746 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ This bundle is served from the URL /cspaceUIPluginProfileMaterials.js, but it does not exist in the filesystem. --> - +
diff --git a/webpack.config.js b/webpack.config.js index 9706031..b86c343 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,14 @@ const { execSync } = require('child_process'); const path = require('path'); const webpack = require('webpack'); +const webpackDevServerConfig = require('./webpackDevServerConfig'); + +/** + * The public path to local webpack assets. This is chosen to have low chance of collision with any + * path on a proxied back-end (e.g., "/cspace/core" or "/cspace-services"). This should start and + * end with slashes. + */ +const publicPath = '/webpack-dev-assets/'; const library = 'cspaceUIPluginProfileMaterials'; const isProduction = process.env.NODE_ENV === 'production'; @@ -24,7 +32,7 @@ try { console.log('Failed to get repository url from npm: %s', err.stderr.toString()); } -const config = { +module.exports = async () => ({ mode: isProduction ? 'production' : 'development', entry: './src/index.js', output: { @@ -33,6 +41,7 @@ const config = { libraryTarget: 'umd', libraryExport: 'default', path: path.resolve(__dirname, 'dist'), + publicPath, }, module: { rules: [ @@ -87,12 +96,10 @@ const config = { resolve: { extensions: ['.js', '.jsx'], }, - devServer: { - historyApiFallback: true, - static: { - directory: __dirname, - }, - }, -}; - -module.exports = config; + devServer: await webpackDevServerConfig({ + library, + localIndex: process.env.npm_config_local_index, + proxyTarget: process.env.npm_config_back_end, + publicPath, + }), +}); diff --git a/webpackDevServerConfig.js b/webpackDevServerConfig.js new file mode 100644 index 0000000..9d43429 --- /dev/null +++ b/webpackDevServerConfig.js @@ -0,0 +1,321 @@ +/* global fetch */ +/* eslint import/no-extraneous-dependencies: "off" */ +/* eslint-disable no-console */ + +const fs = require('fs'); + +const { + createProxyMiddleware, + responseInterceptor, +} = require('http-proxy-middleware'); + +/** + * Generates a regular expression that matches a script URL for a given library. + * + * @param library The name of the library. + * @returns A regular expression that detects if the library is used on an HTML page. + */ +const scriptUrlPattern = (library) => new RegExp(`src=".*?/${library}(@.*?)?(\\.min)?\\.js"`, 'g'); + +/** + * Determines if an HTML page uses a given library. + * + * @param page The HTML content of the page. + * @param library The name of the library. + * @returns true if the page uses the library; false otherwise. + */ +const pageUsesLibrary = (page, library) => scriptUrlPattern(library).test(page); + +/** + * Determines if a given library is a CSpace UI plugin that can be injected into an HTML page. + * + * @param page The HTML content of the page. + * @param library The name of the library. + * @returns true if the library is a plugin that can be injected; false otherwise. + */ +const canInjectLibraryAsPlugin = (page, library) => ( + library.startsWith('cspaceUIPlugin') + && page.includes('cspaceUI({') +); + +/** + * Verifies that a given target URL can be used as a back-end for a given library under + * development. If not, print a message and exit. + * + * A URL can be used as a back end if: + * - It is a valid URL. + * - It is reachable. + * - It returns HTML content that we know how to inject the library into, i.e. it has a + * conventional CSpace UI index.html page. + * + * @param proxyTarget The URL to verify. + * @param library The name of the library. + */ +const verifyTarget = async (proxyTarget) => { + try { + // eslint-disable-next-line no-unused-vars + const verifiedUrl = new URL(proxyTarget); + } catch (error) { + console.error(`The back-end URL ${proxyTarget} is not a valid URL.`); + process.exit(1); + } + + let response; + + try { + response = await fetch(proxyTarget); + } catch (error) { + response = null; + } + + if (!(response && response.ok)) { + console.error(`The back-end URL ${proxyTarget} is not reachable.`); + process.exit(1); + } +}; + +/** + * Inject an element containing a status message into a CSpace HTML page. + * + * @param page The HTML content of the page. + * @param status The status message. + * @returns The HTML content of the page with the status message injected. + */ +const injectStatusElement = (page, status) => page.replace( + '', + ` + + + `, +); + +/** + * Generates a webpack dev server configuration object. + */ +module.exports = async ({ + library, + localIndex, + proxyTarget, + publicPath, +}) => { + if (process.env.npm_lifecycle_event !== 'devserver') { + return undefined; + } + + if (!proxyTarget) { + console.info('Serving local files.'); + console.info('Edit index.html to configure the CollectionSpace UI.'); + console.info(); + + return { + static: { + directory: __dirname, + }, + historyApiFallback: true, + }; + } + + await verifyTarget(proxyTarget); + + console.info(`Proxying to a remote CollectionSpace server at ${proxyTarget}`); + + if (localIndex) { + if (!fs.existsSync(localIndex)) { + console.error(`The local index file ${localIndex} does not exist.`); + process.exit(1); + } + + console.info('The UI configuration on the remote server will be ignored.'); + console.info(`Edit ${localIndex} to configure the CollectionSpace UI.`); + } else { + console.info('The UI configuration on the remote server will be used.'); + } + + console.info(); + + const proxyTargetUrl = new URL(proxyTarget); + + /** + * Rewrite a location header (as received in a 3xx response). This changes back-end URLs to + * point to the local server instead. + * + * @param res The response. + * @param req The request. + */ + const rewriteLocationHeader = (res, req) => { + const location = res.getHeader('location'); + + if (!location) { + return; + } + + const locationUrl = new URL(location, proxyTargetUrl); + + if (locationUrl.host !== proxyTargetUrl.host) { + return; + } + + const requestHost = req.headers.host; + + if (!requestHost) { + return; + } + + locationUrl.protocol = 'http'; + locationUrl.host = requestHost; + + res.setHeader('location', locationUrl.href); + }; + + /** + * Injects the library under development into a CSpace HTML page. + * + * @param page The HTML content of the page. + * @returns The HTML content of the page with the library injected. + */ + const injectDevScript = (page, req) => { + // If this package is being used in the page, replace it with the local dev build. + + if (pageUsesLibrary(page, library)) { + return page.replace( + scriptUrlPattern(library), + `src="${publicPath}${library}.js"`, + ); + } + + // This package isn't being used in the page. If the page appears to use the CSpace UI and this + // package appears to be a CSpace UI plugin, inject a script tag for it, and add it to the + // UI plugin configuration. + + if (canInjectLibraryAsPlugin(page, library)) { + const pageWithScript = page.replace( + '', + ` + + + `, + ); + + const pluginsPattern = /plugins:\s+\[\s+(.*?),?\s+\]/s; + + if (pluginsPattern.test(pageWithScript)) { + return pageWithScript.replace( + pluginsPattern, + (match, existingPlugins) => ( + `plugins: [ + ${existingPlugins}, + ${library}(), + ]` + ), + ); + } + + return pageWithScript.replace( + 'cspaceUI({', + `cspaceUI({ + plugins: [ + ${library}(), + ], + `, + ); + } + + console.warn(`Couldn't inject the library under development into the HTML page at ${req.originalUrl}`); + + return page; + }; + + /** + * Rewrites an HTML response. + * + * @param responseBuffer A buffer containing the response body. + * @param req The request. + * @returns The rewritten response body. + */ + const rewriteHTML = (responseBuffer, req) => { + const requestHost = req.headers.host; + + if (!requestHost) { + return responseBuffer; + } + + const page = responseBuffer.toString('utf8'); + const pageWithDevScript = injectDevScript(page, req); + + return injectStatusElement( + pageWithDevScript, + `devserver: running local package ${library} with back-end ${proxyTarget}`, + ); + }; + + const replaceHTML = () => { + const page = fs.readFileSync(localIndex).toString('utf8'); + + return injectStatusElement( + page, + `devserver: running local index file ${localIndex} with back-end ${proxyTarget}`, + ); + }; + + const proxyMiddleware = createProxyMiddleware({ + changeOrigin: true, + headers: { + origin: proxyTarget, + }, + onProxyRes: responseInterceptor( + async (responseBuffer, proxyRes, req, res) => { + rewriteLocationHeader(res, req); + + if (res.statusCode >= 200 && res.statusCode < 300) { + const contentType = res.getHeader('content-type'); + + if (contentType && contentType.startsWith('text/html')) { + if (localIndex) { + return replaceHTML(); + } + + return rewriteHTML(responseBuffer, req); + } + } + + return responseBuffer; + }, + ), + proxyTimeout: 10000, + secure: false, + selfHandleResponse: true, + target: proxyTarget, + timeout: 10000, + }); + + return { + static: { + directory: __dirname, + publicPath, + }, + setupMiddlewares: (middlewares) => { + middlewares.push({ + name: 'cspace-proxy', + path: '/', + middleware: proxyMiddleware, + }); + + return middlewares; + }, + }; +};