0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-12-01 04:12:23 +01:00
posthog/frontend/utils.mjs
Marius Andra 4ab14a83c0
feat(frontend): build that reload, multiple build scripts (#10175)
* use better paths

* move more logic to `utils.mjs`, support builds scripts from other folders
2022-06-07 13:33:32 +00:00

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,
}
}