2022-06-13 14:47:07 +02:00
|
|
|
import type {BuildOptions} from 'esbuild'
|
|
|
|
import esbuild from 'esbuild'
|
2022-07-25 10:37:34 +02:00
|
|
|
import {readdir, readFile, stat, writeFile} from 'fs/promises'
|
2022-06-13 14:47:07 +02:00
|
|
|
import {mapValues} from 'lodash-es'
|
2022-07-25 10:37:34 +02:00
|
|
|
import path from 'path'
|
2022-06-13 14:47:07 +02:00
|
|
|
import React from 'react'
|
|
|
|
import {renderToStaticMarkup} from 'react-dom/server'
|
2022-06-17 21:23:35 +02:00
|
|
|
import {ServerStyleSheet} from 'styled-components'
|
2022-07-25 10:37:34 +02:00
|
|
|
import {definedGlobals} from '../../../theatre/devEnv/definedGlobals'
|
|
|
|
import {createEsbuildLiveReloadTools} from './createEsbuildLiveReloadTools'
|
|
|
|
import {createProxyServer} from './createProxyServer'
|
|
|
|
import {createServerForceClose} from './createServerForceClose'
|
2022-06-17 21:23:35 +02:00
|
|
|
import {PlaygroundPage} from './home/PlaygroundPage'
|
2022-06-15 20:23:26 +02:00
|
|
|
import {openForOS} from './openForOS'
|
2022-07-25 10:37:34 +02:00
|
|
|
import {timer} from './timer'
|
2022-06-15 20:23:26 +02:00
|
|
|
import {tryMultiplePorts} from './tryMultiplePorts'
|
2022-06-13 14:47:07 +02:00
|
|
|
|
2022-06-13 15:09:32 +02:00
|
|
|
const playgroundDir = (folder: string) => path.join(__dirname, '..', folder)
|
|
|
|
const buildDir = playgroundDir('build')
|
2022-06-24 14:13:58 +02:00
|
|
|
const srcDir = playgroundDir('src')
|
2022-06-13 15:09:32 +02:00
|
|
|
const sharedDir = playgroundDir('src/shared')
|
|
|
|
const personalDir = playgroundDir('src/personal')
|
|
|
|
const testDir = playgroundDir('src/tests')
|
2022-06-13 14:47:07 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
export async function start(options: {
|
2022-07-25 10:37:34 +02:00
|
|
|
/** enable live reload and watching stuff */
|
2022-06-15 20:23:26 +02:00
|
|
|
dev: boolean
|
2022-08-02 15:11:19 +02:00
|
|
|
/** make some UI elements predictable by setting the __IS_VISUAL_REGRESSION_TESTING value on window */
|
|
|
|
isVisualRegressionTesting: boolean
|
2022-07-25 10:37:34 +02:00
|
|
|
serve?: {
|
|
|
|
findAvailablePort: boolean
|
|
|
|
openBrowser: boolean
|
|
|
|
waitBeforeStartingServer?: Promise<void>
|
|
|
|
/** defaults to 8080 */
|
|
|
|
defaultPort?: number
|
|
|
|
}
|
2022-06-15 20:23:26 +02:00
|
|
|
}): Promise<{stop(): Promise<void>}> {
|
2022-07-25 10:37:34 +02:00
|
|
|
const defaultPort = options.serve?.defaultPort ?? 8080
|
2022-06-15 20:23:26 +02:00
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
const liveReload =
|
|
|
|
options.serve && options.dev ? createEsbuildLiveReloadTools() : undefined
|
2022-06-15 20:23:26 +02:00
|
|
|
|
2022-06-24 14:13:58 +02:00
|
|
|
type PlaygroundExample = {
|
|
|
|
useHtml?: string
|
|
|
|
entryFilePath: string
|
|
|
|
outDir: string
|
|
|
|
}
|
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
type Groups = {
|
|
|
|
[group: string]: {
|
2022-06-24 14:13:58 +02:00
|
|
|
[module: string]: PlaygroundExample
|
2022-06-13 14:47:07 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
// Collect all entry directories per module per group
|
2022-07-25 10:37:34 +02:00
|
|
|
const groups: Groups = await Promise.all(
|
|
|
|
[sharedDir, personalDir, testDir].map(async (groupDir) =>
|
|
|
|
readdir(groupDir)
|
|
|
|
.then(async (groupDirItems) => [
|
|
|
|
path.basename(groupDir),
|
|
|
|
await Promise.all(
|
|
|
|
groupDirItems.map(
|
|
|
|
async (
|
|
|
|
moduleDirName,
|
|
|
|
): Promise<[string, PlaygroundExample | undefined]> => {
|
|
|
|
const entryKey = path.basename(moduleDirName)
|
|
|
|
const entryFilePath = path.join(
|
|
|
|
groupDir,
|
|
|
|
moduleDirName,
|
|
|
|
'index.tsx',
|
|
|
|
)
|
2022-06-24 14:13:58 +02:00
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
if (
|
|
|
|
!(await stat(entryFilePath)
|
|
|
|
.then((s) => s.isFile())
|
|
|
|
.catch(() => false))
|
|
|
|
)
|
|
|
|
return [entryKey, undefined]
|
2022-06-24 14:13:58 +02:00
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
return [
|
|
|
|
entryKey,
|
|
|
|
{
|
|
|
|
// Including your own html file for playground is an experimental feature,
|
|
|
|
// it's not quite ready for "prime time" and advertising to the masses until
|
|
|
|
// it properly handles file watching.
|
|
|
|
// It's good for now, since we can use it for some demos, just make sure that
|
|
|
|
// you add a comment to the custom index.html file saying that you have to
|
|
|
|
// restart playground server entirely to see changes.
|
|
|
|
useHtml: await readFile(
|
|
|
|
path.join(groupDir, moduleDirName, 'index.html'),
|
|
|
|
'utf-8',
|
|
|
|
).catch(() => undefined),
|
|
|
|
entryFilePath,
|
|
|
|
outDir: path.join(
|
|
|
|
buildDir,
|
|
|
|
path.basename(groupDir),
|
|
|
|
moduleDirName,
|
|
|
|
),
|
2022-06-24 14:13:58 +02:00
|
|
|
},
|
2022-07-25 10:37:34 +02:00
|
|
|
]
|
|
|
|
},
|
|
|
|
),
|
|
|
|
).then((entries) =>
|
|
|
|
Object.fromEntries(
|
|
|
|
entries.filter((entry) => entry[1] !== undefined),
|
2022-06-15 20:23:26 +02:00
|
|
|
),
|
2022-07-25 10:37:34 +02:00
|
|
|
),
|
|
|
|
])
|
|
|
|
.catch(() =>
|
2022-06-15 20:23:26 +02:00
|
|
|
// If the group dir doesn't exist, we just set its entry to undefined
|
2022-07-25 10:37:34 +02:00
|
|
|
[path.basename(groupDir), undefined],
|
|
|
|
),
|
|
|
|
),
|
2022-06-15 20:23:26 +02:00
|
|
|
)
|
2022-07-25 10:37:34 +02:00
|
|
|
.then((entries) =>
|
|
|
|
Object.fromEntries(
|
|
|
|
// and then filter it out.
|
|
|
|
entries.filter((entry) => entry[1] !== undefined),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
.catch(wrapCatch('reading group dirs'))
|
2022-06-15 20:23:26 +02:00
|
|
|
|
|
|
|
// Collect all entry files
|
|
|
|
const entryPoints = Object.values(groups)
|
|
|
|
.flatMap((group) => Object.values(group))
|
2022-06-24 14:13:58 +02:00
|
|
|
.map((module) => module.entryFilePath)
|
2022-06-15 20:23:26 +02:00
|
|
|
|
|
|
|
// Collect all output directories
|
2022-06-24 14:13:58 +02:00
|
|
|
const outModules = Object.values(groups).flatMap((group) =>
|
|
|
|
Object.values(group),
|
2022-06-15 20:23:26 +02:00
|
|
|
)
|
|
|
|
|
2022-06-17 21:23:35 +02:00
|
|
|
// Render home page contents
|
|
|
|
const homeHtml = (() => {
|
|
|
|
const sheet = new ServerStyleSheet()
|
|
|
|
try {
|
|
|
|
const html = renderToStaticMarkup(
|
|
|
|
sheet.collectStyles(
|
|
|
|
React.createElement(PlaygroundPage, {
|
|
|
|
groups: mapValues(groups, (group) => Object.keys(group)),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
const styleTags = sheet.getStyleTags() // or sheet.getStyleElement();
|
|
|
|
sheet.seal()
|
|
|
|
return {
|
|
|
|
head: styleTags,
|
|
|
|
html,
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
// handle error
|
|
|
|
console.error(error)
|
|
|
|
sheet.seal()
|
|
|
|
process.exit(1)
|
|
|
|
}
|
|
|
|
})()
|
2022-06-15 20:23:26 +02:00
|
|
|
|
|
|
|
const _initialBuild = timer('esbuild initial playground entry point builds')
|
|
|
|
|
|
|
|
const esbuildConfig: BuildOptions = {
|
|
|
|
entryPoints,
|
|
|
|
bundle: true,
|
|
|
|
sourcemap: true,
|
|
|
|
outdir: buildDir,
|
|
|
|
target: ['firefox88'],
|
|
|
|
loader: {
|
|
|
|
'.png': 'file',
|
|
|
|
'.glb': 'file',
|
|
|
|
'.gltf': 'file',
|
2022-09-27 23:23:59 +02:00
|
|
|
'.mp3': 'file',
|
|
|
|
'.ogg': 'file',
|
2022-06-15 20:23:26 +02:00
|
|
|
'.svg': 'dataurl',
|
|
|
|
},
|
|
|
|
define: {
|
|
|
|
...definedGlobals,
|
2022-08-02 15:11:19 +02:00
|
|
|
'window.__IS_VISUAL_REGRESSION_TESTING': JSON.stringify(
|
|
|
|
options.isVisualRegressionTesting,
|
|
|
|
),
|
2022-06-15 20:23:26 +02:00
|
|
|
},
|
|
|
|
banner: liveReload?.esbuildBanner,
|
2022-06-17 21:23:35 +02:00
|
|
|
watch: liveReload?.esbuildWatch && {
|
|
|
|
onRebuild(error, result) {
|
|
|
|
esbuildWatchStop = result?.stop ?? esbuildWatchStop
|
|
|
|
liveReload?.esbuildWatch.onRebuild?.(error, result)
|
|
|
|
},
|
|
|
|
},
|
2022-06-24 14:21:18 +02:00
|
|
|
plugins: [
|
|
|
|
{
|
|
|
|
name: 'watch playground assets',
|
|
|
|
setup(build) {
|
|
|
|
build.onStart(() => {})
|
|
|
|
build.onLoad(
|
|
|
|
{
|
|
|
|
filter: /index\.tsx?$/,
|
|
|
|
},
|
2022-07-25 10:37:34 +02:00
|
|
|
async (loadFile) => {
|
2022-06-24 14:21:18 +02:00
|
|
|
const indexHtmlPath = loadFile.path.replace(
|
|
|
|
/index\.tsx?$/,
|
|
|
|
'index.html',
|
|
|
|
)
|
|
|
|
const relToSrc = path.relative(srcDir, indexHtmlPath)
|
|
|
|
const isInSrcFolder = !relToSrc.startsWith('..')
|
|
|
|
if (isInSrcFolder) {
|
2022-07-25 10:37:34 +02:00
|
|
|
const newHtml = await readFile(indexHtmlPath, 'utf-8').catch(
|
|
|
|
() => undefined,
|
2022-06-24 14:21:18 +02:00
|
|
|
)
|
|
|
|
if (newHtml) {
|
2022-07-25 10:37:34 +02:00
|
|
|
await writeFile(
|
2022-06-24 14:21:18 +02:00
|
|
|
path.resolve(buildDir, relToSrc),
|
|
|
|
newHtml.replace(
|
|
|
|
/<\/body>/,
|
|
|
|
`<script src="${path.join(
|
|
|
|
'/',
|
|
|
|
relToSrc,
|
|
|
|
'../index.js',
|
|
|
|
)}"></script></body>`,
|
|
|
|
),
|
2022-07-25 10:37:34 +02:00
|
|
|
).catch(
|
|
|
|
wrapCatch(
|
|
|
|
`loading index.tsx creates corresponding index.html for ${relToSrc}`,
|
|
|
|
),
|
2022-06-24 14:21:18 +02:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
watchFiles: [indexHtmlPath],
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
2022-06-15 20:23:26 +02:00
|
|
|
}
|
2022-06-13 14:47:07 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
let esbuildWatchStop: undefined | (() => void)
|
2022-06-13 14:47:07 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
await esbuild
|
|
|
|
.build(esbuildConfig)
|
|
|
|
.finally(() => _initialBuild.stop())
|
2022-07-25 10:37:34 +02:00
|
|
|
.catch(
|
2022-06-15 20:23:26 +02:00
|
|
|
// if in dev mode, permit continuing to watch even if there was an error
|
2022-07-25 10:37:34 +02:00
|
|
|
options.dev
|
|
|
|
? () => Promise.resolve()
|
|
|
|
: wrapCatch(`failed initial esbuild.build`),
|
|
|
|
)
|
2022-06-15 20:23:26 +02:00
|
|
|
.then(async (buildResult) => {
|
|
|
|
esbuildWatchStop = buildResult?.stop
|
|
|
|
// Read index.html template
|
2022-07-25 10:37:34 +02:00
|
|
|
const index = await readFile(
|
|
|
|
path.join(__dirname, 'index.html'),
|
|
|
|
'utf8',
|
|
|
|
).catch(wrapCatch('reading index.html template'))
|
2022-06-15 20:23:26 +02:00
|
|
|
await Promise.all([
|
|
|
|
// Write home page
|
2022-06-13 14:47:07 +02:00
|
|
|
writeFile(
|
2022-06-15 20:23:26 +02:00
|
|
|
path.join(buildDir, 'index.html'),
|
2022-06-17 21:23:35 +02:00
|
|
|
index
|
|
|
|
.replace(/<\/head>/, `${homeHtml.head}<\/head>`)
|
|
|
|
.replace(/<body>/, `<body>${homeHtml.html}`),
|
2022-06-15 20:23:26 +02:00
|
|
|
'utf-8',
|
2022-07-25 10:37:34 +02:00
|
|
|
).catch(wrapCatch('writing build index.html')),
|
2022-06-15 20:23:26 +02:00
|
|
|
// Write module pages
|
2022-06-24 14:13:58 +02:00
|
|
|
...outModules.map((outModule) =>
|
2022-06-15 20:23:26 +02:00
|
|
|
writeFile(
|
2022-06-24 14:13:58 +02:00
|
|
|
path.join(outModule.outDir, 'index.html'),
|
2022-06-17 21:23:35 +02:00
|
|
|
// Insert the script
|
2022-06-24 14:13:58 +02:00
|
|
|
(outModule.useHtml ?? index).replace(
|
2022-06-17 21:23:35 +02:00
|
|
|
/<\/body>/,
|
|
|
|
`<script src="${path.join(
|
|
|
|
'/',
|
2022-06-24 14:13:58 +02:00
|
|
|
path.relative(buildDir, outModule.outDir),
|
2022-06-17 21:23:35 +02:00
|
|
|
'index.js',
|
|
|
|
)}"></script></body>`,
|
2022-06-15 20:23:26 +02:00
|
|
|
),
|
|
|
|
'utf-8',
|
2022-07-25 10:37:34 +02:00
|
|
|
).catch(
|
|
|
|
wrapCatch(
|
|
|
|
`writing index.html for ${path.relative(
|
|
|
|
buildDir,
|
|
|
|
outModule.outDir,
|
|
|
|
)}`,
|
|
|
|
),
|
2022-06-15 20:23:26 +02:00
|
|
|
),
|
2022-06-13 15:09:32 +02:00
|
|
|
),
|
2022-06-15 20:23:26 +02:00
|
|
|
])
|
|
|
|
})
|
|
|
|
.catch((err) => {
|
2022-07-25 10:37:34 +02:00
|
|
|
console.error('build.ts: esbuild or html files writing error', err)
|
2022-06-15 20:23:26 +02:00
|
|
|
return process.exit(1)
|
2022-06-13 15:09:32 +02:00
|
|
|
})
|
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
// Only start dev server in serve, otherwise just run build and that's it
|
|
|
|
if (!options.serve) {
|
2022-06-15 20:23:26 +02:00
|
|
|
return {
|
|
|
|
stop() {
|
|
|
|
esbuildWatchStop?.()
|
|
|
|
return Promise.resolve()
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
2022-06-13 15:09:32 +02:00
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
const {serve} = options
|
|
|
|
await serve.waitBeforeStartingServer
|
2022-06-13 15:09:32 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
// We start ESBuild serve with no build config because it doesn't need to build
|
|
|
|
// anything, we are already using ESBuild watch.
|
|
|
|
/** See https://esbuild.github.io/api/#serve-return-values */
|
|
|
|
const esbuildServe = await esbuild.serve({servedir: buildDir}, {})
|
2022-06-13 15:09:32 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
const proxyServer = createProxyServer(liveReload?.handleRequest, {
|
|
|
|
hostname: '0.0.0.0',
|
|
|
|
port: esbuildServe.port,
|
2022-06-13 15:09:32 +02:00
|
|
|
})
|
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
const proxyForceExit = createServerForceClose(proxyServer)
|
2022-07-25 10:37:34 +02:00
|
|
|
const portTries = serve.findAvailablePort ? 10 : 1
|
2022-06-15 20:23:26 +02:00
|
|
|
const portChosen = await tryMultiplePorts(defaultPort, portTries, proxyServer)
|
2022-06-13 15:09:32 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
const hostedAt = `http://localhost:${portChosen}`
|
2022-06-13 15:09:32 +02:00
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
console.log('Playground running at', hostedAt)
|
2022-06-13 15:09:32 +02:00
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
if (serve.openBrowser) {
|
2022-06-15 20:23:26 +02:00
|
|
|
setTimeout(() => {
|
|
|
|
if (!liveReload?.hasOpenConnections()) openForOS(hostedAt)
|
|
|
|
}, 1000)
|
2022-06-13 15:09:32 +02:00
|
|
|
}
|
|
|
|
|
2022-06-15 20:23:26 +02:00
|
|
|
return {
|
|
|
|
stop() {
|
|
|
|
esbuildServe.stop()
|
|
|
|
esbuildWatchStop?.()
|
|
|
|
return Promise.all([proxyForceExit(), esbuildServe.wait]).then(() => {
|
|
|
|
// map to void for type defs
|
|
|
|
})
|
|
|
|
},
|
2022-06-13 15:09:32 +02:00
|
|
|
}
|
|
|
|
}
|
2022-06-24 14:13:58 +02:00
|
|
|
|
2022-07-25 10:37:34 +02:00
|
|
|
function wrapCatch(message: string) {
|
|
|
|
return (err: any) => {
|
|
|
|
return Promise.reject(`Rejected "${message}":\n ${err.toString()}`)
|
2022-06-24 14:13:58 +02:00
|
|
|
}
|
|
|
|
}
|