From 4d4d970278b3195147c47f3e6d4d15ff4e8158f2 Mon Sep 17 00:00:00 2001 From: Cole Lawrence Date: Mon, 13 Jun 2022 09:09:32 -0400 Subject: [PATCH] dev playground: Support auto port assignment + refactor --- packages/playground/devEnv/build.ts | 251 +++++++++++++++++++--------- 1 file changed, 170 insertions(+), 81 deletions(-) diff --git a/packages/playground/devEnv/build.ts b/packages/playground/devEnv/build.ts index 796c135..880f63c 100644 --- a/packages/playground/devEnv/build.ts +++ b/packages/playground/devEnv/build.ts @@ -3,7 +3,7 @@ import {writeFile, readFile} from 'fs/promises' import path from 'path' import type {BuildOptions} from 'esbuild' import esbuild from 'esbuild' -import type {ServerResponse} from 'http' +import type {IncomingMessage, ServerResponse} from 'http' import {definedGlobals} from '../../../theatre/devEnv/buildUtils' import {mapValues} from 'lodash-es' import {createServer, request} from 'http' @@ -11,17 +11,57 @@ import {spawn} from 'child_process' import React from 'react' import {renderToStaticMarkup} from 'react-dom/server' import {Home} from './Home' +import type {Server} from 'net' -const playgroundDir = path.join(__dirname, '..') -const buildDir = path.join(playgroundDir, 'build') -const sharedDir = path.join(playgroundDir, 'src/shared') -const personalDir = path.join(playgroundDir, 'src/personal') -const testDir = path.join(playgroundDir, 'src/tests') +const playgroundDir = (folder: string) => path.join(__dirname, '..', folder) +const buildDir = playgroundDir('build') +const sharedDir = playgroundDir('src/shared') +const personalDir = playgroundDir('src/personal') +const testDir = playgroundDir('src/tests') -const dev = /^--dev|-d$/.test(process.argv[process.argv.length - 1]) -const port = 8080 +const dev = process.argv.find((arg) => ['--dev', '-d'].includes(arg)) != null +const defaultPort = 8080 -const clients: ServerResponse[] = [] +const liveReload = + (dev || undefined) && + ((): { + handleRequest(req: IncomingMessage, res: ServerResponse): boolean + hasOpenConnections(): boolean + esbuildBanner: esbuild.BuildOptions['banner'] + esbuildWatch: esbuild.WatchMode + } => { + const openResponses: ServerResponse[] = [] + return { + handleRequest(req, res) { + // If special /esbuild url requested, subscribe clients to changes + if (req.url === '/esbuild') { + openResponses.push( + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }), + ) + return true + } + return false + }, + hasOpenConnections() { + return openResponses.length > 0 + }, + esbuildBanner: { + js: ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();', + }, + esbuildWatch: { + onRebuild(error) { + // Notify clients on rebuild + openResponses.forEach((res) => res.write('data: update\n\n')) + openResponses.length = 0 + console.error(error ? error : 'Reloading...') + }, + }, + } + })() type Groups = { [group: string]: { @@ -40,11 +80,15 @@ const groups: Groups = Object.fromEntries( return [ path.basename(groupDir), Object.fromEntries( - readdirSync(groupDir).map((moduleDir) => [ - path.basename(moduleDir), + readdirSync(groupDir).map((moduleDirName) => [ + path.basename(moduleDirName), { - entryDir: path.join(groupDir, moduleDir), - outDir: path.join(buildDir, path.basename(groupDir), moduleDir), + entryDir: path.join(groupDir, moduleDirName), + outDir: path.join( + buildDir, + path.basename(groupDir), + moduleDirName, + ), }, ]), ), @@ -79,7 +123,7 @@ const config: BuildOptions = { entryPoints, bundle: true, sourcemap: true, - outdir: path.join(playgroundDir, 'build'), + outdir: playgroundDir('build'), target: ['firefox88'], loader: { '.png': 'file', @@ -91,19 +135,8 @@ const config: BuildOptions = { ...definedGlobals, 'window.__IS_VISUAL_REGRESSION_TESTING': 'true', }, - banner: dev - ? { - js: ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();', - } - : undefined, - watch: dev && { - onRebuild(error) { - // Notify clients on rebuild - clients.forEach((res) => res.write('data: update\n\n')) - clients.length = 0 - console.log(error ? error : 'Reloading...') - }, - }, + banner: liveReload?.esbuildBanner, + watch: liveReload?.esbuildWatch, } esbuild @@ -134,7 +167,7 @@ esbuild console.log(err) return process.exit(1) }) - .then(() => { + .then(async () => { // Only start dev server in dev, otherwise just run build and that's it if (!dev) { return @@ -142,58 +175,114 @@ esbuild // We start ESBuild serve with no build config because it doesn't need to build // anything, we are already using ESBuild watch. - esbuild - .serve({servedir: path.join(playgroundDir, 'build')}, {}) - .then(({port: esbuildPort}) => { - // Create proxy - createServer((req, res) => { - const {url, method, headers} = req - // If special /esbuild url requested, subscribe clients to changes - if (req.url === '/esbuild') { - return clients.push( - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }), - ) - } - // Otherwise forward requests to ESBuild server - req.pipe( - request( - { - hostname: '0.0.0.0', - port: esbuildPort, - path: url, - method, - headers, - }, - (prxRes) => { - res.writeHead(prxRes.statusCode!, prxRes.headers) - prxRes.pipe(res, {end: true}) - }, - ), - {end: true}, - ) - }).listen(port, () => { - console.log('Playground running at', 'http://localhost:' + port) - }) + const {port: esbuildPort} = await esbuild.serve( + {servedir: playgroundDir('build')}, + {}, + ) - // If not in CI, try to spawn a browser - if (!process.env.CI) { - setTimeout(() => { - const open = { - darwin: ['open'], - linux: ['xdg-open'], - win32: ['cmd', '/c', 'start'], - } - const platform = process.platform as keyof typeof open - if (clients.length === 0) - spawn(open[platform][0], [ - ...open[platform].slice(1), - `http://localhost:${port}`, - ]) - }, 1000) - } - }) + const proxyServer = createServer((req, res) => { + const {url, method, headers} = req + if (liveReload?.handleRequest(req, res)) { + return + } + + // Otherwise forward requests to ESBuild server + req.pipe( + request( + { + hostname: '0.0.0.0', + port: esbuildPort, + path: url, + method, + headers, + }, + (prxRes) => { + res.writeHead(prxRes.statusCode!, prxRes.headers) + prxRes.pipe(res, {end: true}) + }, + ), + {end: true}, + ) + }) + + const isCI = process.env.CI + const portTries = isCI ? 1 : 10 + const portChosen = await tryMultiplePorts( + defaultPort, + portTries, + proxyServer, + ) + + const hostedAt = `http://localhost:${portChosen}` + + console.log('Playground running at', hostedAt) + + // If not in CI, try to spawn a browser + if (!isCI) { + setTimeout(() => { + if (!liveReload?.hasOpenConnections()) openForOS(hostedAt) + }, 1000) + } }) + +function openForOS(hostedAt: string) { + const open = { + darwin: ['open'], + linux: ['xdg-open'], + win32: ['cmd', '/c', 'start'], + } + const platform = process.platform as keyof typeof open + if (open[platform]) { + spawn(open[platform][0], [...open[platform].slice(1), hostedAt]) + } else { + console.error( + `Failed to open (${hostedAt}) for unconfigured platform (${platform})`, + ) + } +} + +async function tryMultiplePorts( + port: number, + tries: number, + server: Server, +): Promise { + let portToTry = port + let firstError = null + let lastError = null + + while (portToTry < port + tries) { + try { + await new Promise((res, rej) => { + const onListening = () => (rm(), res(true)) + const onError = () => (rm(), rej()) + const rm = () => { + server.off('error', onError) + server.off('listening', onListening) + } + + server + .listen(portToTry) + .on('listening', onListening) + .on('error', onError) + }) + + firstError = null + lastError = null + break // found a working port + } catch (err) { + if (!firstError) firstError = err + lastError = err + portToTry += 1 + } + } + + if (firstError) { + console.error(firstError) + console.error(lastError) + throw new Error( + `Failed to find port starting at ${port} with ${tries} tries.`, + ) + } + + return portToTry +}