mirror of
https://github.com/PostHog/posthog.git
synced 2024-12-01 04:12:23 +01:00
4ab14a83c0
* use better paths * move more logic to `utils.mjs`, support builds scripts from other folders
405 lines
14 KiB
JavaScript
405 lines
14 KiB
JavaScript
import { sassPlugin } from 'esbuild-sass-plugin'
|
|
import { lessLoader } from 'esbuild-plugin-less'
|
|
import * as path from 'path'
|
|
import express from 'express'
|
|
import cors from 'cors'
|
|
import fse from 'fs-extra'
|
|
import { build, analyzeMetafile } from 'esbuild'
|
|
import chokidar from 'chokidar'
|
|
|
|
const defaultHost = process.argv.includes('--host') && process.argv.includes('0.0.0.0') ? '0.0.0.0' : 'localhost'
|
|
const defaultPort = 8234
|
|
|
|
export const isDev = process.argv.includes('--dev')
|
|
|
|
export const lessPlugin = lessLoader({ javascriptEnabled: true })
|
|
|
|
export function copyPublicFolder(srcDir, destDir) {
|
|
fse.copySync(srcDir, destDir, { overwrite: true }, function (err) {
|
|
if (err) {
|
|
console.error(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
export function copyIndexHtml(
|
|
absWorkingDir = '.',
|
|
from = 'src/index.html',
|
|
to = 'dist/index.html',
|
|
entry = 'index',
|
|
chunks = {},
|
|
entrypoints = []
|
|
) {
|
|
// Takes an html file, `from`, and some artifacts from esbuild, and injects
|
|
// some javascript that will load these artifacts dynamically, based on an
|
|
// expected `window.JS_URL` javascript variable.
|
|
//
|
|
// `JS_URL` is expected to be injected into the html as part of Django html
|
|
// template rendering. We do not know what JS_URL should be at runtime, as,
|
|
// for instance, on PostHog Cloud, we want to use the official Posthog
|
|
// Docker image, but serve the js and it's dependencies from e.g. CloudFront
|
|
const buildId = new Date().valueOf()
|
|
|
|
const relativeFiles = entrypoints.map((e) => path.relative(path.resolve(absWorkingDir, 'dist'), e))
|
|
const jsFile = relativeFiles.length > 0 ? relativeFiles.find((e) => e.endsWith('.js')) : `${entry}.js?t=${buildId}`
|
|
const cssFile =
|
|
relativeFiles.length > 0 ? relativeFiles.find((e) => e.endsWith('.css')) : `${entry}.css?t=${buildId}`
|
|
|
|
const scriptCode = `
|
|
window.ESBUILD_LOAD_SCRIPT = async function (file) {
|
|
try {
|
|
await import((window.JS_URL || '') + '/static/' + file)
|
|
} catch (error) {
|
|
console.error('Error loading chunk: "' + file + '"')
|
|
console.error(error)
|
|
}
|
|
}
|
|
window.ESBUILD_LOAD_SCRIPT(${JSON.stringify(jsFile)})
|
|
`
|
|
|
|
const chunkCode = `
|
|
window.ESBUILD_LOADED_CHUNKS = new Set();
|
|
window.ESBUILD_LOAD_CHUNKS = function(name) {
|
|
const chunks = ${JSON.stringify(chunks)}[name] || [];
|
|
for (const chunk of chunks) {
|
|
if (!window.ESBUILD_LOADED_CHUNKS.has(chunk)) {
|
|
window.ESBUILD_LOAD_SCRIPT('chunk-'+chunk+'.js');
|
|
window.ESBUILD_LOADED_CHUNKS.add(chunk);
|
|
}
|
|
}
|
|
}
|
|
window.ESBUILD_LOAD_CHUNKS('index');
|
|
`
|
|
|
|
// Snippet to dynamically load the css based on window.JS_URL
|
|
const cssLoader = `
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = (window.JS_URL || '') + "/static/" + ${JSON.stringify(cssFile)};
|
|
document.head.appendChild(link)
|
|
`
|
|
|
|
fse.writeFileSync(
|
|
path.resolve(absWorkingDir, to),
|
|
fse.readFileSync(path.resolve(absWorkingDir, from), { encoding: 'utf-8' }).replace(
|
|
'</head>',
|
|
` <script type="application/javascript">
|
|
// NOTE: the link for the stylesheet will be added just
|
|
// after this script block. The react code will need the
|
|
// body to have been parsed before it is able to interact
|
|
// with it and add anything to it.
|
|
//
|
|
// Fingers crossed the browser waits for the stylesheet to
|
|
// load such that it's in place when react starts
|
|
// adding elements to the DOM
|
|
${cssFile ? cssLoader : ''}
|
|
${scriptCode}
|
|
${Object.keys(chunks).length > 0 ? chunkCode : ''}
|
|
</script>
|
|
</head>`
|
|
)
|
|
)
|
|
}
|
|
|
|
/** Makes copies: "index-TMOJQ3VI.js" -> "index.js" */
|
|
export function createHashlessEntrypoints(absWorkingDir, entrypoints) {
|
|
for (const entrypoint of entrypoints) {
|
|
const withoutHash = entrypoint.replace(/-([A-Z0-9]+).(js|css)$/, '.$2')
|
|
fse.writeFileSync(
|
|
path.resolve(absWorkingDir, withoutHash),
|
|
fse.readFileSync(path.resolve(absWorkingDir, entrypoint))
|
|
)
|
|
}
|
|
}
|
|
|
|
export const commonConfig = {
|
|
sourcemap: true,
|
|
incremental: isDev,
|
|
minify: !isDev,
|
|
resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.scss', '.css', '.less'],
|
|
publicPath: '/static',
|
|
assetNames: 'assets/[name]-[hash]',
|
|
chunkNames: '[name]-[hash]',
|
|
// no hashes in dev mode for faster reloads --> we save the old hash in index.html otherwise
|
|
entryNames: isDev ? '[dir]/[name]' : '[dir]/[name]-[hash]',
|
|
plugins: [sassPlugin(), lessPlugin],
|
|
define: {
|
|
global: 'globalThis',
|
|
'process.env.NODE_ENV': isDev ? '"development"' : '"production"',
|
|
},
|
|
loader: {
|
|
'.png': 'file',
|
|
'.svg': 'file',
|
|
'.woff': 'file',
|
|
'.woff2': 'file',
|
|
'.mp3': 'file',
|
|
'.lottie': 'file',
|
|
},
|
|
metafile: true,
|
|
}
|
|
|
|
function getInputFiles(result) {
|
|
return new Set(
|
|
result?.metafile
|
|
? Object.keys(result.metafile.inputs)
|
|
.map((key) => (key.includes(':') ? key.split(':')[1] : key))
|
|
.map((key) => (key.startsWith('/') ? key : path.resolve(process.cwd(), key)))
|
|
: []
|
|
)
|
|
}
|
|
|
|
function getChunks(result) {
|
|
const chunks = {}
|
|
for (const output of Object.values(result.metafile?.outputs || {})) {
|
|
if (!output.entryPoint || output.entryPoint.startsWith('node_modules')) {
|
|
continue
|
|
}
|
|
const importStatements = output.imports.filter(
|
|
(i) => i.kind === 'import-statement' && i.path.startsWith('frontend/dist/chunk-')
|
|
)
|
|
const exports = output.exports.filter((e) => e !== 'default' && e !== 'scene')
|
|
if (importStatements.length > 0 && (exports.length > 0 || output.entryPoint === 'frontend/src/index.tsx')) {
|
|
chunks[exports[0] || 'index'] = importStatements.map((i) =>
|
|
i.path.replace('frontend/dist/chunk-', '').replace('.js', '')
|
|
)
|
|
}
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
export async function buildInParallel(configs, { onBuildStart, onBuildComplete } = {}) {
|
|
await Promise.all(
|
|
configs.map((config) =>
|
|
buildOrWatch({
|
|
...config,
|
|
onBuildStart,
|
|
onBuildComplete,
|
|
})
|
|
)
|
|
)
|
|
}
|
|
|
|
/** Get the main ".js" and ".css" files for a build */
|
|
function getBuiltEntryPoints(config, result) {
|
|
let outfiles = []
|
|
if (config.outdir) {
|
|
// convert "src/index.tsx" --> /a/posthog/frontend/dist/index.js
|
|
outfiles = config.entryPoints.map((file) =>
|
|
path
|
|
.resolve(config.absWorkingDir, file)
|
|
.replace('/src/', '/dist/')
|
|
.replace(/\.[^\.]+$/, '.js')
|
|
)
|
|
} else if (config.outfile) {
|
|
outfiles = [path.resolve(config.absWorkingDir, config.outfile)]
|
|
}
|
|
|
|
const builtFiles = []
|
|
for (const outfile of outfiles) {
|
|
// convert "/a/something.tsx" --> "/a/something-"
|
|
const fileNoExt = outfile.replace(/\.[^/]+$/, '')
|
|
// find if we built a .js or .css file that matches
|
|
for (const file of Object.keys(result.metafile.outputs)) {
|
|
const absoluteFile = path.resolve(process.cwd(), file)
|
|
if (
|
|
(absoluteFile.startsWith(`${fileNoExt}-`) && (file.endsWith('.js') || file.endsWith('.css'))) ||
|
|
absoluteFile === `${fileNoExt}.js` ||
|
|
absoluteFile === `${fileNoExt}.css`
|
|
) {
|
|
builtFiles.push(absoluteFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
return builtFiles
|
|
}
|
|
|
|
let buildsInProgress = 0
|
|
|
|
export async function buildOrWatch(config) {
|
|
const { absWorkingDir, name, onBuildStart, onBuildComplete, ..._config } = config
|
|
|
|
let buildPromise = null
|
|
let buildAgain = false
|
|
let inputFiles = new Set([])
|
|
|
|
// The aim is to make sure that when we request a build, then:
|
|
// - we only build one thing at a time
|
|
// - if we request a build when one is running, we'll queue it to start right after this build
|
|
// - if we request a build multiple times when one is running, only one will start right after this build
|
|
// - notify with callbacks when builds start and when they end.
|
|
async function debouncedBuild() {
|
|
if (buildPromise) {
|
|
buildAgain = true
|
|
return
|
|
}
|
|
buildAgain = false
|
|
if (buildsInProgress === 0) {
|
|
server?.pauseServer()
|
|
}
|
|
buildsInProgress++
|
|
onBuildStart?.(config)
|
|
reloadLiveServer()
|
|
buildPromise = runBuild()
|
|
const buildResponse = await buildPromise
|
|
buildPromise = null
|
|
await onBuildComplete?.(config, buildResponse)
|
|
buildsInProgress--
|
|
if (buildsInProgress === 0) {
|
|
server?.resumeServer()
|
|
}
|
|
if (isDev && buildAgain) {
|
|
void debouncedBuild()
|
|
}
|
|
}
|
|
|
|
let result = null
|
|
let buildCount = 0
|
|
|
|
async function runBuild() {
|
|
buildCount++
|
|
const time = new Date()
|
|
if (buildCount === 1) {
|
|
console.log(`🧱 Building${name ? ` "${name}"` : ''}`)
|
|
try {
|
|
result = await build({ ...commonConfig, ..._config })
|
|
console.log(`🥇 Built${name ? ` "${name}"` : ''} in ${(new Date() - time) / 1000}s`)
|
|
} catch (error) {
|
|
console.log(`🛑 Building${name ? ` "${name}"` : ''} failed in ${(new Date() - time) / 1000}s`)
|
|
process.exit(1) // must exit since with result === null, result.rebuild() won't work
|
|
}
|
|
} else {
|
|
try {
|
|
result = await result.rebuild()
|
|
console.log(`🔄 Rebuilt${name ? ` "${name}"` : ''} in ${(new Date() - time) / 1000}s`)
|
|
} catch (e) {
|
|
console.log(`🛑 Rebuilding${name ? ` "${name}"` : ''} failed in ${(new Date() - time) / 1000}s`)
|
|
}
|
|
}
|
|
inputFiles = getInputFiles(result)
|
|
|
|
return {
|
|
entrypoints: getBuiltEntryPoints(config, result),
|
|
chunks: getChunks(result),
|
|
...result.metafile,
|
|
}
|
|
}
|
|
|
|
if (isDev) {
|
|
chokidar
|
|
.watch(path.resolve(absWorkingDir, 'src'), {
|
|
ignored: /.*(Type|\.test\.stories)\.[tj]sx$/,
|
|
ignoreInitial: true,
|
|
})
|
|
.on('all', async (event, filePath) => {
|
|
if (inputFiles.size === 0) {
|
|
await buildPromise
|
|
}
|
|
if (inputFiles.has(filePath)) {
|
|
void debouncedBuild()
|
|
}
|
|
})
|
|
}
|
|
|
|
await debouncedBuild()
|
|
}
|
|
|
|
export async function printResponse(response, { compact = true, color = true, verbose = false, ...opts } = {}) {
|
|
let text = await analyzeMetafile('metafile' in response ? response.metafile : response, {
|
|
color,
|
|
verbose,
|
|
...opts,
|
|
})
|
|
if (compact) {
|
|
text = text
|
|
.split('\n')
|
|
.filter((l) => !l.match(/^ [^\n]+$/g) && l.trim())
|
|
.join('\n')
|
|
}
|
|
console.log(text)
|
|
}
|
|
|
|
let clients = new Set()
|
|
|
|
function reloadLiveServer() {
|
|
clients.forEach((client) => client.write(`data: reload\n\n`))
|
|
}
|
|
|
|
let server
|
|
export function startDevServer(absWorkingDir) {
|
|
if (isDev) {
|
|
console.log(`👀 Starting dev server`)
|
|
server = startServer({ absWorkingDir })
|
|
return server
|
|
} else {
|
|
console.log(`🛳 Starting production build`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function startServer(opts = {}) {
|
|
const host = opts.host || defaultHost
|
|
const port = opts.port || defaultPort
|
|
const absWorkingDir = opts.absWorkingDir || '.'
|
|
|
|
console.log(`🍱 Starting server at http://${host}:${port}`)
|
|
|
|
let resolve = null
|
|
let ifPaused = null
|
|
function pauseServer() {
|
|
if (!ifPaused) {
|
|
ifPaused = new Promise((r) => (resolve = r))
|
|
}
|
|
}
|
|
function resumeServer() {
|
|
resolve?.()
|
|
ifPaused = null
|
|
}
|
|
resumeServer()
|
|
|
|
const app = express()
|
|
app.on('error', function (e) {
|
|
if (e.code === 'EADDRINUSE') {
|
|
console.error(`🛑 http://${host}:${port} is already in use. Trying another port.`)
|
|
} else {
|
|
console.error(`🛑 ${e}`)
|
|
}
|
|
process.exit(1)
|
|
})
|
|
app.use(cors())
|
|
app.get('/_reload', (request, response) => {
|
|
response.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
Connection: 'keep-alive',
|
|
'Cache-Control': 'no-cache',
|
|
})
|
|
clients.add(response)
|
|
request.on('close', () => clients.delete(response))
|
|
})
|
|
app.get('*', async (req, res) => {
|
|
if (req.url.startsWith('/static/')) {
|
|
if (ifPaused) {
|
|
if (!ifPaused.logged) {
|
|
console.log('⌛️ Waiting for build to complete...')
|
|
ifPaused.logged = true
|
|
}
|
|
await ifPaused
|
|
}
|
|
const pathFromUrl = req.url.replace(/^\/static\//, '')
|
|
const filePath = path.resolve(absWorkingDir, 'dist', pathFromUrl)
|
|
// protect against "/../" urls
|
|
if (filePath.startsWith(path.resolve(absWorkingDir, 'dist'))) {
|
|
res.sendFile(filePath.split('?')[0])
|
|
return
|
|
}
|
|
}
|
|
res.sendFile(path.resolve(absWorkingDir, 'dist', 'index.html'))
|
|
})
|
|
app.listen(port)
|
|
|
|
return {
|
|
pauseServer,
|
|
resumeServer,
|
|
}
|
|
}
|