diff --git a/examples/concurrent/package.json b/examples/concurrent/package.json index dbab008cc..a2360226d 100644 --- a/examples/concurrent/package.json +++ b/examples/concurrent/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "start": "anansi serve --dev ./src/index.tsx", - "start:server": "anansi serve -a --pubPath=/assets/ ./dist-server/App.js", + "start:server": "WEBPACK_PUBLIC_PATH='/assets/' anansi serve ./dist-server/App.js", "build": "WEBPACK_PUBLIC_PATH='/assets/' webpack --mode=production", "build:server": "WEBPACK_PUBLIC_PATH='/assets/' webpack --mode=production --target=node --env entrypath=index.server.tsx", "build:clean": "rm -rf dist && rm -rf dist-server", diff --git a/packages/cli/README.md b/packages/cli/README.md index 5e6229628..45702ee86 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -62,23 +62,24 @@ anansi add testing ## Running SSR ```bash -Usage: run serve [options] +Usage: anansi serve [options] runs server for SSR projects Arguments: - entrypath Path to entrypoint + entrypath Path to entrypoint Options: - -p, --pubPath Where to serve assets from - -d, --dev Run devserver rather than using previously compiled output - -a, --serveAssets [only prod] also serves client assets - -h, --help display help for command + --pubPath Where to serve assets from + -d, --dev Run devserver rather than using previously compiled output + -a, --serveAssets [non-dev] also serves client assets + -p, --serveProxy [non-dev] uses webpack proxy config + -h, --help display help for command ``` ```json { "start": "anansi serve --dev ./src/index.tsx", - "start:server": "anansi serve -a --pubPath=/assets/ ./dist-server/App.js", + "start:server": "anansi serve ./dist-server/App.js", } ``` diff --git a/packages/cli/run.js b/packages/cli/run.js index 5e0ceef7e..be0328795 100755 --- a/packages/cli/run.js +++ b/packages/cli/run.js @@ -106,12 +106,13 @@ program .command('serve') .description('runs server for SSR projects') .argument('', 'Path to entrypoint') - .option('-p, --pubPath ', 'Where to serve assets from') + .option('--pubPath ', 'Where to serve assets from') .option( '-d, --dev', 'Run devserver rather than using previously compiled output', ) - .option('-a, --serveAssets', '[only prod] also serves client assets') + .option('-a, --serveAssets', '[non-dev] also serves client assets') + .option('-p, --serveProxy', '[non-dev] uses webpack proxy config') .action(async (entrypath, options) => { try { const { serve, devServe } = await import('@anansi/core/scripts'); diff --git a/packages/core/package.json b/packages/core/package.json index b5c09a8c4..dd4bf120d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -86,6 +86,7 @@ "cross-fetch": "^3.1.5", "fs-monkey": "^1.0.3", "history": "^5.3.0", + "http-proxy-middleware": "^2.0.6", "import-fresh": "^3.3.0", "memfs": "^3.4.1", "ora": "^5.0.0", diff --git a/packages/core/src/scripts/getProxyMiddlewares.ts b/packages/core/src/scripts/getProxyMiddlewares.ts new file mode 100644 index 000000000..1e075945e --- /dev/null +++ b/packages/core/src/scripts/getProxyMiddlewares.ts @@ -0,0 +1,129 @@ +import { + RequestHandler, + ProxyConfigArray, + Response, + Request, + NextFunction, + ByPass, + ProxyConfigArrayItem, + ProxyConfigMap, +} from 'webpack-dev-server'; + +// Essentially taken from https://github.com/webpack/webpack-dev-server/blob/b5e5b67398f97c7a2934e12ebe34fb03cc06c473/lib/Server.js#L2123 +export default function getProxyMiddlewares( + proxyConfig: ProxyConfigArrayItem | ProxyConfigMap | ProxyConfigArray, +) { + const middlewares: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { createProxyMiddleware } = require('http-proxy-middleware'); + + const proxy = + !Array.isArray(proxyConfig) && + typeof proxyConfig === 'object' && + Object.keys(proxyConfig).length && + Object.keys(proxyConfig)[0].startsWith('/') + ? Object.entries(proxyConfig).map(([path, v]) => ({ path, ...v })) + : proxyConfig; + + const getProxyMiddleware = ( + proxyConfig: ProxyConfigArrayItem, + ): RequestHandler | undefined => { + // It is possible to use the `bypass` method without a `target` or `router`. + // However, the proxy middleware has no use in this case, and will fail to instantiate. + if (proxyConfig.target) { + const context = proxyConfig.context || proxyConfig.path; + + return createProxyMiddleware(/** @type {string} */ context, proxyConfig); + } + + if (proxyConfig.router) { + return createProxyMiddleware(proxyConfig); + } + }; + + /** + * Assume a proxy configuration specified as: + * proxy: [ + * { + * context: "value", + * ...options, + * }, + * // or: + * function() { + * return { + * context: "context", + * ...options, + * }; + * } + * ] + */ + proxy.forEach(proxyConfigOrCallback => { + let proxyMiddleware: RequestHandler | undefined; + + let proxyConfig = + typeof proxyConfigOrCallback === 'function' + ? proxyConfigOrCallback() + : proxyConfigOrCallback; + + proxyMiddleware = getProxyMiddleware(proxyConfig); + + /* TODO: figure out how to make this work + if (proxyConfig.ws) { + this.webSocketProxies.push(proxyMiddleware); + } + */ + + const handler = async (req: Request, res: Response, next: NextFunction) => { + if (typeof proxyConfigOrCallback === 'function') { + const newProxyConfig = proxyConfigOrCallback(req, res, next); + + if (newProxyConfig !== proxyConfig) { + proxyConfig = newProxyConfig; + proxyMiddleware = getProxyMiddleware(proxyConfig); + } + } + + // - Check if we have a bypass function defined + // - In case the bypass function is defined we'll retrieve the + // bypassUrl from it otherwise bypassUrl would be null + // TODO remove in the next major in favor `context` and `router` options + const bypassUrl: ByPass | null = + typeof proxyConfig.bypass === 'function' + ? await proxyConfig.bypass(req, res, proxyConfig) + : null; + + if (typeof bypassUrl === 'boolean') { + // skip the proxy + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + req.url = null; + next(); + } else if (typeof bypassUrl === 'string') { + // byPass to that url + req.url = bypassUrl; + next(); + } else if (proxyMiddleware) { + return proxyMiddleware(req, res, next); + } else { + next(); + } + }; + + middlewares.push({ + name: 'http-proxy-middleware', + middleware: handler, + }); + // Also forward error requests to the proxy so it can handle them. + middlewares.push({ + name: 'http-proxy-middleware-error-handler', + middleware: ( + error: Error, + req: Request, + res: Response, + next: NextFunction, + ) => handler(req, res, next), + }); + }); + + return middlewares; +} diff --git a/packages/core/src/scripts/serve.ts b/packages/core/src/scripts/serve.ts index 93fe20de2..0f663b9de 100644 --- a/packages/core/src/scripts/serve.ts +++ b/packages/core/src/scripts/serve.ts @@ -11,6 +11,7 @@ import compress from 'compression'; import 'cross-fetch/polyfill'; import { Render } from './types'; +import getProxyMiddlewares from './getProxyMiddlewares'; // run directly from node if (require.main === module) { @@ -25,13 +26,24 @@ if (require.main === module) { export default function serve( entrypoint: string, - options: { serveAssets?: boolean } = {}, + options: { serveAssets?: boolean; serveProxy?: boolean } = {}, ) { const PORT = process.env.PORT || 8080; const loader = ora('Initializing').start(); - const manifestPath = getManifestPathFromWebpackconfig(); + const webpackConfig: ( + env: any, + argv: any, + // eslint-disable-next-line @typescript-eslint/no-var-requires + ) => webpack.Configuration = require(require.resolve( + // TODO: use normal resolution algorithm to find webpack file + path.join(process.cwd(), 'webpack.config'), + )); + + const manifestPath = getManifestPathFromWebpackconfig( + webpackConfig({}, { mode: 'production' }), + ); const readFile = promisify(diskFs.readFile); let server: Server | undefined; @@ -69,31 +81,9 @@ export default function serve( //@ts-ignore wrappingApp.use(compress()); - // SERVER SIDE RENDERING - // eslint-disable-next-line @typescript-eslint/no-var-requires - const render: Render = require(path.join( - process.cwd(), - entrypoint, - )).default; - const handlers = [ - handleErrors(async function (req: any, res: any) { - if (req.url.endsWith('favicon.ico')) { - res.statusCode = 404; - res.setHeader('Content-type', 'text/html'); - res.send('not found'); - return; - } - res.socket.on('error', (error: unknown) => { - console.error('Fatal', error); - }); - - await render(clientManifest, req, res); - }), - ]; - // ASSETS if (options.serveAssets) { - handlers.unshift( + wrappingApp.use( async ( req: Request | IncomingMessage, res: any, @@ -117,7 +107,7 @@ export default function serve( res.contentType(filename); res.send(fileContent); } catch (e) { - return next(e); + return next(); } } else { next(); @@ -126,7 +116,43 @@ export default function serve( ); } - wrappingApp.get('/*', ...handlers); + // PROXIES + if (options.serveProxy) { + const devConfig: webpack.Configuration = webpackConfig( + {}, + { mode: 'development' }, + ); + if (devConfig.devServer?.proxy) { + const middlewares = getProxyMiddlewares(devConfig.devServer?.proxy); + if (middlewares) { + wrappingApp.use(...middlewares.map(({ middleware }) => middleware)); + } + } + } + + // SERVER SIDE RENDERING + // eslint-disable-next-line @typescript-eslint/no-var-requires + const render: Render = require(path.join( + process.cwd(), + entrypoint, + )).default; + + wrappingApp.get( + '/*', + handleErrors(async function (req: any, res: any) { + if (req.url.endsWith('favicon.ico')) { + res.statusCode = 404; + res.setHeader('Content-type', 'text/html'); + res.send('not found'); + return; + } + res.socket.on('error', (error: unknown) => { + console.error('Fatal', error); + }); + + await render(clientManifest, req, res); + }), + ); server = wrappingApp .listen(PORT, () => { @@ -167,12 +193,9 @@ export default function serve( }); } -function getManifestPathFromWebpackconfig() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const webpackConfig: webpack.Configuration = require(require.resolve( - // TODO: use normal resolution algorithm to find webpack file - path.join(process.cwd(), 'webpack.config'), - ))({}, { mode: 'production' }); +function getManifestPathFromWebpackconfig( + webpackConfig: webpack.Configuration, +) { const manifestFilename: string = ( webpackConfig?.plugins?.find(plugin => { diff --git a/packages/core/src/scripts/startDevserver.ts b/packages/core/src/scripts/startDevserver.ts index 2f8a25705..f02824385 100644 --- a/packages/core/src/scripts/startDevserver.ts +++ b/packages/core/src/scripts/startDevserver.ts @@ -21,8 +21,6 @@ import { BoundRender } from './types'; // run directly from node if (require.main === module) { const entrypoint = process.argv[2]; - //process.env.WEBPACK_PUBLIC_HOST = `http://localhost:${PORT}`; this breaks compatibility with stackblitz - process.env.WEBPACK_PUBLIC_PATH = '/assets/'; if (!entrypoint) { console.log(`Usage: start-anansi `); @@ -169,13 +167,6 @@ export default function startDevServer( // write to memory filesystem so we can import { ...webpackConfigs[0].devServer, - /*client: { - ...webpackConfigs[0].devServer?.client, - webSocketURL: { - ...webpackConfigs[0].devServer?.client.webSocketURL, - port: 8080, - }, - },*/ devMiddleware: { ...webpackConfigs[0]?.devServer?.devMiddleware, outputFileSystem: { diff --git a/yarn.lock b/yarn.lock index 6f8e73797..3d7ecb2a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -138,6 +138,7 @@ __metadata: cross-fetch: ^3.1.5 fs-monkey: ^1.0.3 history: ^5.3.0 + http-proxy-middleware: ^2.0.6 import-fresh: ^3.3.0 jest: 28.1.0 memfs: ^3.4.1 @@ -16504,6 +16505,24 @@ __metadata: languageName: node linkType: hard +"http-proxy-middleware@npm:^2.0.6": + version: 2.0.6 + resolution: "http-proxy-middleware@npm:2.0.6" + dependencies: + "@types/http-proxy": ^1.17.8 + http-proxy: ^1.18.1 + is-glob: ^4.0.1 + is-plain-obj: ^3.0.0 + micromatch: ^4.0.2 + peerDependencies: + "@types/express": ^4.17.13 + peerDependenciesMeta: + "@types/express": + optional: true + checksum: 2ee85bc878afa6cbf34491e972ece0f5be0a3e5c98a60850cf40d2a9a5356e1fc57aab6cff33c1fc37691b0121c3a42602d2b1956c52577e87a5b77b62ae1c3a + languageName: node + linkType: hard + "http-proxy@npm:^1.18.1": version: 1.18.1 resolution: "http-proxy@npm:1.18.1"