mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-25 02:49:32 +01:00
04f19e42eb
* chore(static-assets): set JS_URL for ECS This is the breakaway change [here](https://github.com/PostHog/posthog/pull/8299#pullrequestreview-864974223) This will need to be merged in before the other change, as otherwise we will end up using the default of /static/ to serve up the frontend in production. * chore(js-loading): Make JS_URL be used at runtime for js loading Previously we would bake the JS_URL in at build time. Instead this changes such that we can change this at runtime by specifying a `JS_URL` env variable. * comment on css loader snippet * Default js_url to / * Leave leading / when concatenating to JS_URL I'd accidentally left this out before * Remove trailing forward-slash from `JS_URL` django setting Prior to the change to make JS_URL applicable at runtime, this variable had no effect, hence the railing slash wasn't an issue. This change fixes the frontend when running with debug true, i.e. when we should be using the dev server for serving the frontend * remove trailing slashes from JS_URL * avoid unexpected concat behaviour * add some comments around style sheet loading * explicitly handle JS_URL not set for css as well
384 lines
13 KiB
JavaScript
384 lines
13 KiB
JavaScript
import { sassPlugin as _sassPlugin } from 'esbuild-sass-plugin'
|
|
import { createImporter } from 'sass-extended-importer'
|
|
import { lessLoader } from 'esbuild-plugin-less'
|
|
import * as path from 'path'
|
|
import * as url from 'url'
|
|
import express from 'express'
|
|
import cors from 'cors'
|
|
import fse from 'fs-extra'
|
|
import { build } 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 __dirname = path.dirname(url.fileURLToPath(import.meta.url))
|
|
export const isDev = process.argv.includes('--dev')
|
|
|
|
export const sassPlugin = _sassPlugin({
|
|
importer: [
|
|
(importUrl) => {
|
|
const [first, ...rest] = importUrl.split('/')
|
|
const paths = {
|
|
'~': 'src',
|
|
scenes: 'src/scenes',
|
|
public: 'public',
|
|
'react-toastify': '../node_modules/react-toastify',
|
|
}
|
|
if (paths[first]) {
|
|
return {
|
|
file: path.resolve(__dirname, paths[first], ...rest),
|
|
}
|
|
}
|
|
},
|
|
createImporter(),
|
|
],
|
|
})
|
|
|
|
export const lessPlugin = lessLoader({ javascriptEnabled: true })
|
|
|
|
export function copyPublicFolder() {
|
|
const srcDir = path.resolve(__dirname, 'public')
|
|
const destDir = path.resolve(__dirname, 'dist')
|
|
|
|
fse.copySync(srcDir, destDir, { overwrite: true }, function (err) {
|
|
if (err) {
|
|
console.error(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
export function copyIndexHtml(
|
|
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(__dirname, '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(${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/${cssFile}"
|
|
document.head.appendChild(link)
|
|
`
|
|
|
|
fse.writeFileSync(
|
|
path.resolve(__dirname, to),
|
|
fse.readFileSync(path.resolve(__dirname, 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
|
|
${cssLoader}
|
|
${scriptCode}
|
|
${Object.keys(chunks).length > 0 ? chunkCode : ''}
|
|
</script>
|
|
</head>`
|
|
)
|
|
)
|
|
}
|
|
|
|
/** Makes copies: "index-TMOJQ3VI.js" -> "index.js" */
|
|
export function createHashlessEntrypoints(entrypoints) {
|
|
for (const entrypoint of entrypoints) {
|
|
const withoutHash = entrypoint.replace(/-([A-Z0-9]+).(js|css)$/, '.$2')
|
|
fse.writeFileSync(path.resolve(withoutHash), fse.readFileSync(path.resolve(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',
|
|
},
|
|
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(__dirname, file)
|
|
.replace('/src/', '/dist/')
|
|
.replace(/\.[^\.]+$/, '.js')
|
|
)
|
|
} else if (config.outfile) {
|
|
outfiles = [config.outfile]
|
|
}
|
|
|
|
const builtFiles = []
|
|
for (const outfile of outfiles) {
|
|
// convert "/a/something.tsx" --> "/a/something-"
|
|
const searchString = `${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(searchString) && (file.endsWith('.js') || file.endsWith('.css'))) {
|
|
builtFiles.push(absoluteFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
return builtFiles
|
|
}
|
|
|
|
export async function buildOrWatch(config) {
|
|
const { 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
|
|
onBuildStart?.(config)
|
|
reloadLiveServer()
|
|
buildPromise = runBuild()
|
|
const buildResponse = await buildPromise
|
|
buildPromise = null
|
|
onBuildComplete?.(config, buildResponse)
|
|
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 {
|
|
chunks: getChunks(result),
|
|
entrypoints: getBuiltEntryPoints(config, result),
|
|
}
|
|
}
|
|
|
|
if (isDev) {
|
|
chokidar
|
|
.watch(path.resolve(__dirname, '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()
|
|
}
|
|
|
|
let clients = new Set()
|
|
|
|
function reloadLiveServer() {
|
|
clients.forEach((client) => client.write(`data: reload\n\n`))
|
|
}
|
|
|
|
export function startServer(opts = {}) {
|
|
const host = opts.host || defaultHost
|
|
const port = opts.port || defaultPort
|
|
|
|
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(__dirname, 'dist', pathFromUrl)
|
|
// protect against "/../" urls
|
|
if (filePath.startsWith(path.resolve(__dirname, 'dist'))) {
|
|
res.sendFile(filePath.split('?')[0])
|
|
return
|
|
}
|
|
}
|
|
res.sendFile(path.resolve(__dirname, 'dist', 'index.html'))
|
|
})
|
|
app.listen(port)
|
|
|
|
return {
|
|
pauseServer,
|
|
resumeServer,
|
|
}
|
|
}
|