Files
zeekay 44a2799881 feat(brand): drop upstream's brand package, and take the react bindings from our own fork
The React bindings, the shim standing in for their peer, and four dead alias
lines all resolved to upstream packages. So did the illustrations, the logo and
the typeface, through `@hanzo/brand`.

`@hanzo/react` now resolves to `@hanzo/insights-react`, which we publish. Its
peer is `@hanzo/insights` by name, so `common/sdk-peer` -- a workspace package
named `posthog-js` purely to satisfy the old peer -- is gone, and with it the
last thing in the tree carrying that name. The fork exports `Insights*` where
upstream exported `PostHog*`, so eight files stop aliasing an upstream
identifier on import, and the eight allowlist entries excusing them are dropped.

`@hanzo/brand` is removed rather than forked. It is licensed PolyForm Strict
1.0.0: noncommercial use only, no derivative works, no redistribution, and the
illustrations are upstream trademarks. Forking it was never available to us, and
neither was the commercial use we were already making of it.

It carried three separate things, and each ends differently. The illustrations
were decoration beside real content, so the 106 call sites keep their component
and lose the artwork -- the shape `lib/components/mascots.tsx` already used. Two
had been repurposed as real icons; those take the house fallback icon. The logo
is real UI chrome, so it is rebuilt on Hanzo's own mark, the same glyph as the
favicon: monochrome and `currentColor`, so it inherits the surrounding text
colour instead of needing a variant per theme, and resolves inside the toolbar's
shadow DOM where the old gradient's `url(#...)` fills did not. The typeface
becomes Geist, already vendored here under SIL OFL and already the house face
everywhere else.

Four alias lines named packages nothing installs. pnpm echoes the override block
into the lockfile verbatim, so they put an upstream name there for nothing.

`bin/debrand --check` now refuses a manifest naming an upstream package unless it
is listed with a reason, keyed by package rather than by file so an exemption
cannot cover a whole manifest's future dependencies. Six remain, each with the
reason it is still upstream's. The check found two nobody had noticed: `cli` and
`rust` rename upstream crates with Cargo's `package =` key, the same aliasing on
a different registry.

The lockfile goes from 94 upstream references to 36.
2026-08-08 19:31:14 -07:00

731 lines
28 KiB
JavaScript

import tailwindcss from '@tailwindcss/postcss'
import autoprefixer from 'autoprefixer'
import chokidar from 'chokidar'
import cors from 'cors'
import cssnano from 'cssnano'
import { analyzeMetafile, context } from 'esbuild'
import { lessLoader } from 'esbuild-plugin-less'
import { polyfillNode } from 'esbuild-plugin-polyfill-node'
import { sassPlugin } from 'esbuild-sass-plugin'
import express from 'express'
import fse from 'fs-extra'
import fs from 'node:fs/promises'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import postcss from 'postcss'
import postcssPresetEnv from 'postcss-preset-env'
import ts from 'typescript'
// Re-exported for one-shot builds outside buildInParallel (e.g. the toolbar loader, which is
// built after the toolbar app build so it can embed the hashed entry filename). Consumers
// depend on @hanzo/esbuilder, not on esbuild directly, so pnpm's strict node_modules
// wouldn't let them import 'esbuild' themselves.
export { build as esbuildBuild } from 'esbuild'
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 function copyPublicFolder(srcDir, destDir) {
fse.copySync(srcDir, destDir, { overwrite: true }, function (err) {
if (err) {
console.error(err)
}
})
}
export function copySnappyWASMFile(absWorkingDir) {
try {
fse.copyFileSync(
path.resolve(absWorkingDir, 'node_modules/snappy-wasm/es/snappy_bg.wasm'),
path.resolve(absWorkingDir, 'dist/snappy_bg.wasm')
)
} catch (error) {
console.warn('Could not copy snappy wasm file:', error.message)
}
}
// Both packages ship a copy of rrweb's image-bitmap worker, each with its own content
// hash in the filename, and which one ends up bundled depends on what imported it.
// Reading only the first is how the build broke: @hanzo/insights-rrweb arrived, its
// hash is the one that got bundled, and the map went looking in the other package.
const RRWEB_PACKAGES = ['node_modules/@hanzo/insights-rrweb/dist', 'node_modules/insights-js/dist']
export function copyRRWebWorkerFiles(absWorkingDir, distSubdir = 'dist') {
// Mirror rrweb's image-bitmap worker sourcemap into the output dir so the
// sourceMappingURL baked into our bundled rrweb resolves under collectstatic.
// ManifestStaticFilesStorage resolves the reference relative to the referencing file's
// directory, so every outdir that bundles rrweb needs its own copy.
//
// Copy from every package that ships one rather than picking: the maps are named by
// content hash, so they cannot collide, and a spare map costs a file while a missing
// one fails collectstatic and takes the whole image build down with it.
const distDir = path.resolve(absWorkingDir, distSubdir)
let copied = 0
for (const pkg of RRWEB_PACKAGES) {
const sourceDir = path.resolve(absWorkingDir, pkg)
try {
for (const file of fse.readdirSync(sourceDir)) {
if (file.startsWith('image-bitmap-data-url-worker-') && file.endsWith('.js.map')) {
fse.copyFileSync(path.join(sourceDir, file), path.join(distDir, file))
copied += 1
}
}
} catch {
// A package that is not installed here is not an error; the next one may hold it.
}
}
if (copied === 0) {
console.warn(`No rrweb worker sourcemap found in ${RRWEB_PACKAGES.join(' or ')} — collectstatic will fail`)
}
}
export function copyIndexHtml(
absWorkingDir = '.',
from = 'src/index.html',
to = 'dist/index.html',
entry = 'index',
chunks = {},
entrypoints = []
) {
// Takes a 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 Insights Cloud, we want to use the official Insights
// 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 jsFileFallback = `${entry}.js?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)
if (file === ${JSON.stringify(jsFile)} && file !== ${JSON.stringify(jsFileFallback)}) {
await import((window.JS_URL || '') + '/static/' + ${JSON.stringify(jsFileFallback)})
}
}
}
window.ESBUILD_LOAD_SCRIPT(${JSON.stringify(jsFile)})
`
// Esbuild "chunks" a scene into possibly hundreds of tiny files. When we load the first few files,
// they tell us which other files to load. This cascading loading is slow. That's why we cache
// the list of chunks per scene, and load them all in parallel when a scene is loaded.
// Don't use chunks in dev mode.
// Django caches the generated index.html, and we'll end up loading the wrong chunks after one change.
const chunksToServe = isDev ? {} : chunks
const chunkCode = `
window.ESBUILD_LOADED_CHUNKS = new Set();
window.ESBUILD_LOAD_CHUNKS = function(name) {
const chunks = ${JSON.stringify(chunksToServe)}[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');
`
// Fallback to non-hashed CSS (with cache-busting build ID) when the hashed
// version fails to load (e.g. CDN returns 403). Mirrors the JS fallback above.
const cssFileFallback = `${entry}.css?t=${buildId}`
const needsCssFallback = cssFile !== cssFileFallback
const cssLoader = `
const link = document.createElement("link");
link.rel = "stylesheet";
link.crossOrigin = "anonymous";
link.href = (window.JS_URL || '') + "/static/" + ${JSON.stringify(cssFile)};
${
needsCssFallback
? `link.onerror = function() {
link.onerror = null;
console.warn('Failed to load stylesheet "' + ${JSON.stringify(cssFile)} + '", trying fallback');
var fallbackLink = document.createElement("link");
fallbackLink.rel = "stylesheet";
fallbackLink.crossOrigin = "anonymous";
fallbackLink.href = (window.JS_URL || '') + "/static/" + ${JSON.stringify(cssFileFallback)};
document.head.appendChild(fallbackLink);
};`
: ''
}
document.head.appendChild(link)
`
fse.writeFileSync(
path.resolve(absWorkingDir, to),
fse.readFileSync(path.resolve(absWorkingDir, from), { encoding: 'utf-8' }).replace(
'</head>',
` <script nonce="{{ request.csp_nonce }}" 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))
)
}
}
const tsconfigPath = isDev ? 'tsconfig.dev.json' : 'tsconfig.json'
const { config: tsconfig } = ts.readConfigFile(
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', tsconfigPath),
ts.sys.readFile
)
/** @type {import('esbuild').BuildOptions} */
export const commonConfig = {
sourcemap: true,
minify: !isDev,
target: tsconfig.compilerOptions.target, // We want the same target as tsconfig, should fail if tsconfig not found
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: [
// monaco-vim imports monaco-editor internals without .js extensions (e.g. monaco-editor/esm/vs/editor/editor.api)
// which esbuild can't resolve through monaco-editor's package.json exports map
{
name: 'resolve-monaco-esm',
setup(build) {
build.onResolve({ filter: /^monaco-editor\/esm\// }, (args) => {
if (args.path.endsWith('.js')) {
return
}
return build.resolve(args.path + '.js', {
kind: args.kind,
resolveDir: args.resolveDir,
})
})
},
},
sassPlugin({
async transform(source, resolveDir, filePath) {
const plugins = [tailwindcss, autoprefixer, postcssPresetEnv({ stage: 0 })]
if (!isDev) {
plugins.push(cssnano({ preset: 'default' }))
}
const result = await postcss(plugins).process(source, { from: filePath })
// Tailwind reports each scanned source file as a PostCSS `dependency` message.
// Surface them as esbuild watchFiles so editing a TSX file re-runs this
// transform on tailwind.css in watch mode.
const watchFiles = result.messages
.filter((message) => message.type === 'dependency' && message.file)
.map((message) => message.file)
return { contents: result.css, loader: 'css', watchFiles }
},
}),
lessLoader({ javascriptEnabled: true }),
polyfillNode({
globals: {
buffer: false,
process: true,
},
polyfills: {
buffer: false,
crypto: false,
process: true,
stream: false,
},
}),
],
alias: {
buffer: 'buffer',
crypto: 'crypto-browserify',
stream: 'stream-browserify',
},
tsconfig: tsconfigPath,
define: {
global: 'globalThis',
'process.env.NODE_ENV': isDev ? '"development"' : '"production"',
},
loader: {
'.ttf': 'file',
'.png': 'file',
'.gif': 'file',
'.svg': 'file',
'.woff': 'file',
'.woff2': 'file',
'.mp3': 'file',
'.sql': 'text',
'.yaml': 'text',
},
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('dist/chunk-')
)
const exports = output.exports.filter((e) => e !== 'default' && e !== 'scene')
const isRootEntry = output.entryPoint === 'src/index.tsx' || output.entryPoint === 'src/exporter/index.tsx'
if (importStatements.length > 0 && (exports.length > 0 || isRootEntry)) {
chunks[exports[0] || 'index'] = importStatements.map((i) =>
i.path.replace('dist/chunk-', '').replace('.js', '')
)
}
}
return chunks
}
function formatBytes(bytes) {
if (bytes >= 1_000_000) {
return `${(bytes / 1_000_000).toFixed(2)} MB`
}
if (bytes >= 1_000) {
return `${(bytes / 1_000).toFixed(1)} KB`
}
return `${bytes} B`
}
function shortenInputPath(inputPath) {
const pnpmMatch = inputPath.match(/node_modules\/\.pnpm\/([^/]+)\/node_modules\/(.+)$/)
if (pnpmMatch) {
const pkg = pnpmMatch[1].replace(/_.*$/, '')
return `${pkg} (${pnpmMatch[2]})`
}
return inputPath
}
export function reportTopChunks(outputs, { topChunks = 10, topContributors = 5, label = 'chunks' } = {}) {
if (!outputs) {
return
}
const chunkEntries = Object.entries(outputs)
.filter(([outputPath]) => /\/chunk-[A-Za-z0-9]+\.js$/.test(outputPath))
.sort((a, b) => b[1].bytes - a[1].bytes)
.slice(0, topChunks)
if (chunkEntries.length === 0) {
return
}
const lines = [`Top ${chunkEntries.length} ${label} by uncompressed size:`]
for (const [outputPath, output] of chunkEntries) {
const inputCount = Object.keys(output.inputs || {}).length
lines.push(` ${formatBytes(output.bytes).padStart(9)} ${path.basename(outputPath)} (${inputCount} inputs)`)
const contributors = Object.entries(output.inputs || {})
.sort((a, b) => b[1].bytesInOutput - a[1].bytesInOutput)
.slice(0, topContributors)
for (const [inputPath, info] of contributors) {
lines.push(` ${formatBytes(info.bytesInOutput).padStart(9)} ${shortenInputPath(inputPath)}`)
}
}
console.info(lines.join('\n'))
}
export async function buildInParallel(configs, { onBuildStart, onBuildComplete } = {}) {
try {
await Promise.all(
configs.map((config) =>
buildOrWatch({
...config,
onBuildStart,
onBuildComplete,
})
)
)
} catch (error) {
// esbuild already prints its own compile errors, but onBuildStart/onBuildComplete
// failures (e.g. finalizeToolbarBuild) would otherwise die silently here.
console.error(error)
if (!isDev) {
process.exit(1)
}
}
if (!isDev) {
process.exit(0)
}
}
/** Get the main ".js" and ".css" files for a build */
function getBuiltEntryPoints(config, result) {
// Normalise entryPoints: support both array form and object form { name: path }
const entryValues = Array.isArray(config.entryPoints) ? config.entryPoints : Object.values(config.entryPoints || {})
// Primary: scan the metafile directly — works regardless of entryNames overrides.
// CSS outputs never have entryPoint set, so we find JS entries first, then locate
// each companion CSS by matching the same base name (hashes differ between JS and CSS).
const jsFiles = []
for (const [outputPath, output] of Object.entries(result.metafile?.outputs || {})) {
if (output.entryPoint && entryValues.includes(output.entryPoint) && outputPath.endsWith('.js')) {
jsFiles.push(path.resolve(process.cwd(), outputPath))
}
}
if (jsFiles.length > 0) {
const allFiles = [...jsFiles]
const allOutputPaths = Object.keys(result.metafile?.outputs || {})
// Pair each entry JS with its companion CSS by stripping the optional
// "-<hash>" suffix. The hash matcher excludes `-` so that a sibling
// entry sharing a prefix can't have its CSS stolen — e.g. an `exporter`
// entry must not match `dist/exporter-extra-HASH.css` (the `-extra-HASH`
// segment contains a `-`, which fails the single-segment match).
// esbuild's default hash is base32 (uppercase + digits, no hyphens) so
// the constraint is compatible with default builds and allows mixed-
// case alphabets too.
const hashSuffixPattern = /(-[^./-]+)?\.js$/
for (const jsFile of jsFiles) {
const baseName = jsFile.replace(hashSuffixPattern, '')
const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const expectedCss = new RegExp(`^${escapedBase}(-[^./-]+)?\\.css$`)
for (const outputPath of allOutputPaths) {
if (!outputPath.endsWith('.css')) {
continue
}
const absoluteCss = path.resolve(process.cwd(), outputPath)
if (expectedCss.test(absoluteCss)) {
allFiles.push(absoluteCss)
}
}
}
return allFiles
}
// Fallback: path-based convention for configs where metafile scan yields nothing
let outfiles = []
if (config.outdir) {
// convert "src/index.tsx" --> /a/insights/frontend/dist/index.js
outfiles = entryValues.map((file) =>
path
.resolve(config.absWorkingDir, file)
.replace('/src/', '/dist/')
.replace(/\.[^.]+$/, '.js')
)
} else if (config.outfile) {
outfiles = [path.resolve(config.absWorkingDir, config.outfile)]
}
const fallbackFiles = []
for (const outfile of outfiles) {
const fileNoExt = outfile.replace(/\.[^/]+$/, '')
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`
) {
fallbackFiles.push(absoluteFile)
}
}
}
return fallbackFiles
}
let buildsInProgress = 0
// Serialize the heavy full-app bundles (Insights App, Exporter). All esbuild contexts in a
// process multiplex over one Go service, so concurrent heavy rebuilds keep both build graphs
// resident at once — the summed peak is what OOM-kills the Docker builder. Each heavy build
// disposes its context before releasing the lock (see runBuild), so the next heavy build reuses
// the freed memory instead of stacking on top of it. Dev is unaffected: it keeps contexts alive
// for incremental watch rebuilds and isn't memory-constrained.
let heavyBuildChain = Promise.resolve()
function runExclusiveHeavy(fn) {
const result = heavyBuildChain.then(fn)
heavyBuildChain = result.then(
() => {},
() => {}
)
return result
}
export async function buildOrWatch(config) {
const { absWorkingDir, name, heavy, onBuildStart, onBuildComplete, writeMetaFile, extraPlugins, ..._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)
buildPromise = runBuild()
const buildResponse = await buildPromise
buildPromise = null
await onBuildComplete?.(config, buildResponse)
buildsInProgress--
if (buildsInProgress === 0) {
server?.resumeServer()
reloadLiveServer()
}
if (isDev && buildAgain) {
void debouncedBuild()
}
}
let esbuildContext = null
let buildCount = 0
const log = (logOpts) => {
const icon = logOpts.success === undefined ? '🧱' : logOpts.success ? '🥇' : '🛑'
let timingSuffix = ''
if (logOpts.time) {
timingSuffix = ` in ${(new Date() - logOpts.time) / 1000}s`
}
const message =
logOpts.success === undefined
? buildCount === 1
? 'Building'
: 'Rebuilding'
: logOpts.success
? buildCount === 1
? 'Built'
: 'Rebuilt'
: buildCount === 1
? 'Building failed'
: 'Rebuilding failed '
console.info(`${icon} ${name ? `"${name}": ` : ''}${message}${timingSuffix}`)
}
async function runBuild() {
if (!esbuildContext) {
const combinedConfig = { ...commonConfig, ..._config }
combinedConfig.plugins = [...commonConfig.plugins, ...(extraPlugins || [])]
esbuildContext = await context(combinedConfig)
}
buildCount++
const time = new Date()
log({ name })
// In production each build runs once then the process exits, so free the build graph
// as soon as it's done instead of letting all contexts pile up until process.exit.
// Dev keeps the context for incremental rebuilds. For heavy builds this dispose happens
// inside the serialization lock, so the next heavy build starts with this one freed.
async function rebuildAndRelease() {
const result = await esbuildContext.rebuild()
if (!isDev) {
await esbuildContext.dispose()
esbuildContext = null
}
return result
}
try {
const buildResult = heavy && !isDev ? await runExclusiveHeavy(rebuildAndRelease) : await rebuildAndRelease()
if (writeMetaFile) {
await fs.writeFile(
`${config.name.toLowerCase().replace(/ /g, '-')}-esbuild-meta.json`,
JSON.stringify(buildResult.metafile)
)
}
inputFiles = getInputFiles(buildResult)
log({ success: true, name, time })
return {
entrypoints: getBuiltEntryPoints(config, buildResult),
chunks: getChunks(buildResult),
...buildResult.metafile,
}
} catch (e) {
if (isDev) {
log({ success: false, name, time })
} else {
throw e
}
}
}
if (isDev) {
chokidar
.watch(
[
path.resolve(absWorkingDir, 'src'),
path.resolve(absWorkingDir, '../common'),
path.resolve(absWorkingDir, '../products/*/manifest.tsx'),
path.resolve(absWorkingDir, '../products/*/frontend/**/*'),
],
{
ignored: /.*(Type|\.test\.stories)\.[tj]sx?$/,
ignoreInitial: true,
}
)
.on('all', async (event, filePath) => {
if (inputFiles.size === 0) {
await buildPromise
}
// Manifests have been updated, so we need to rebuild urls.
if (filePath.includes('manifest.tsx')) {
await import('../../frontend/build-products.mjs')
}
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(/^ {3}[^\n]+$/g) && l.trim())
.join('\n')
}
console.info(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.info(`👀 Starting dev server`)
server = startServer({ absWorkingDir })
return server
}
console.info(`🛳 Starting production build`)
return null
}
export function startServer(opts = {}) {
const host = opts.host || defaultHost
const port = opts.port || defaultPort
const absWorkingDir = opts.absWorkingDir || '.'
console.info(`🍱 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.info('⌛️ 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,
}
}