dev playground: Support auto port assignment + refactor

This commit is contained in:
Cole Lawrence 2022-06-13 09:09:32 -04:00
parent 8f0f76df54
commit 4d4d970278

View file

@ -3,7 +3,7 @@ import {writeFile, readFile} from 'fs/promises'
import path from 'path' import path from 'path'
import type {BuildOptions} from 'esbuild' import type {BuildOptions} from 'esbuild'
import esbuild from 'esbuild' import esbuild from 'esbuild'
import type {ServerResponse} from 'http' import type {IncomingMessage, ServerResponse} from 'http'
import {definedGlobals} from '../../../theatre/devEnv/buildUtils' import {definedGlobals} from '../../../theatre/devEnv/buildUtils'
import {mapValues} from 'lodash-es' import {mapValues} from 'lodash-es'
import {createServer, request} from 'http' import {createServer, request} from 'http'
@ -11,17 +11,57 @@ import {spawn} from 'child_process'
import React from 'react' import React from 'react'
import {renderToStaticMarkup} from 'react-dom/server' import {renderToStaticMarkup} from 'react-dom/server'
import {Home} from './Home' import {Home} from './Home'
import type {Server} from 'net'
const playgroundDir = path.join(__dirname, '..') const playgroundDir = (folder: string) => path.join(__dirname, '..', folder)
const buildDir = path.join(playgroundDir, 'build') const buildDir = playgroundDir('build')
const sharedDir = path.join(playgroundDir, 'src/shared') const sharedDir = playgroundDir('src/shared')
const personalDir = path.join(playgroundDir, 'src/personal') const personalDir = playgroundDir('src/personal')
const testDir = path.join(playgroundDir, 'src/tests') const testDir = playgroundDir('src/tests')
const dev = /^--dev|-d$/.test(process.argv[process.argv.length - 1]) const dev = process.argv.find((arg) => ['--dev', '-d'].includes(arg)) != null
const port = 8080 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 = { type Groups = {
[group: string]: { [group: string]: {
@ -40,11 +80,15 @@ const groups: Groups = Object.fromEntries(
return [ return [
path.basename(groupDir), path.basename(groupDir),
Object.fromEntries( Object.fromEntries(
readdirSync(groupDir).map((moduleDir) => [ readdirSync(groupDir).map((moduleDirName) => [
path.basename(moduleDir), path.basename(moduleDirName),
{ {
entryDir: path.join(groupDir, moduleDir), entryDir: path.join(groupDir, moduleDirName),
outDir: path.join(buildDir, path.basename(groupDir), moduleDir), outDir: path.join(
buildDir,
path.basename(groupDir),
moduleDirName,
),
}, },
]), ]),
), ),
@ -79,7 +123,7 @@ const config: BuildOptions = {
entryPoints, entryPoints,
bundle: true, bundle: true,
sourcemap: true, sourcemap: true,
outdir: path.join(playgroundDir, 'build'), outdir: playgroundDir('build'),
target: ['firefox88'], target: ['firefox88'],
loader: { loader: {
'.png': 'file', '.png': 'file',
@ -91,19 +135,8 @@ const config: BuildOptions = {
...definedGlobals, ...definedGlobals,
'window.__IS_VISUAL_REGRESSION_TESTING': 'true', 'window.__IS_VISUAL_REGRESSION_TESTING': 'true',
}, },
banner: dev banner: liveReload?.esbuildBanner,
? { watch: liveReload?.esbuildWatch,
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...')
},
},
} }
esbuild esbuild
@ -134,7 +167,7 @@ esbuild
console.log(err) console.log(err)
return process.exit(1) return process.exit(1)
}) })
.then(() => { .then(async () => {
// Only start dev server in dev, otherwise just run build and that's it // Only start dev server in dev, otherwise just run build and that's it
if (!dev) { if (!dev) {
return return
@ -142,58 +175,114 @@ esbuild
// We start ESBuild serve with no build config because it doesn't need to build // We start ESBuild serve with no build config because it doesn't need to build
// anything, we are already using ESBuild watch. // anything, we are already using ESBuild watch.
esbuild const {port: esbuildPort} = await esbuild.serve(
.serve({servedir: path.join(playgroundDir, 'build')}, {}) {servedir: 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)
})
// If not in CI, try to spawn a browser const proxyServer = createServer((req, res) => {
if (!process.env.CI) { const {url, method, headers} = req
setTimeout(() => { if (liveReload?.handleRequest(req, res)) {
const open = { return
darwin: ['open'], }
linux: ['xdg-open'],
win32: ['cmd', '/c', 'start'], // Otherwise forward requests to ESBuild server
} req.pipe(
const platform = process.platform as keyof typeof open request(
if (clients.length === 0) {
spawn(open[platform][0], [ hostname: '0.0.0.0',
...open[platform].slice(1), port: esbuildPort,
`http://localhost:${port}`, path: url,
]) method,
}, 1000) 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<number> {
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
}