Files

156 lines
6.0 KiB
JavaScript

// Bundles the browser half's modules into the single self-contained `client.js`
// this package is served through.
//
// `dsh-client-modules` hands a plugin bundle one lazy CJS factory whose
// `require` resolves against the module table (platform seeds such as `react`,
// and other plugins' rows) — never against relative files. A client entry must
// therefore be one self-contained script that registers exactly one factory,
// and every module of this package has to be inlined into it. Source of truth:
// `src/client/**`; the generated `client.js` at the repository root is the
// artifact DSH serves, and it is committed so installing this plugin still needs
// no build step.
//
// Usage:
// node scripts/build-client.mjs # rebuild client.js
// node --test tests # also asserts the artifact is fresh
import { readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const entryPoint = resolve(repoRoot, 'src/client/index.js')
const targetPath = resolve(repoRoot, 'client.js')
/** The module-table name this bundle's factory is registered under. */
const packageName = JSON.parse(await readFile(resolve(repoRoot, 'package.json'), 'utf8')).name
// The wrapper. Everything the bundler emits goes INSIDE the factory, so the
// module table's own `require` is in scope for it: `require('react')` runs when
// the bundle materializes, not when the script is executed, which is the
// laziness contract the module system documents.
const banner = [
'// Generated by scripts/build-client.mjs — do not edit this file.',
'// Source of truth: src/client/**. Run `pnpm run build` after changing it.',
`// Served by dsh-client-modules at /plugins/${packageName}/client.js.`,
'window.__ModuleLoader__.load({',
` id: '${packageName}',`,
' factory(require) {',
' const module = { exports: {} }',
' // Same object as `module.exports`; kept so the emitted body may assign',
' // through either binding.',
' const exports = module.exports',
'',
].join('\n')
const footer = [
'',
' return module.exports',
' },',
'})',
'',
].join('\n')
/** Referenced modules that are NOT platform seeds, and must reach `dsh.client.external`. */
const external = ['react']
/** How the wrapper is recognized in a built artifact; the test suite reuses it. */
export const WRAPPER = {
load: 'window.__ModuleLoader__.load({',
id: `id: '${packageName}',`,
factory: 'factory(require) {',
ret: 'return module.exports',
footer: '})',
}
/**
* Load esbuild through a dynamic import, so a checkout without devDependencies
* gets one actionable sentence instead of a module-resolution stack.
* @returns the esbuild module.
*/
async function loadEsbuild() {
try {
return await import('esbuild')
} catch {
throw new Error('esbuild is missing — run `pnpm install` in the repository root first')
}
}
/**
* Refuse to ship an artifact that does not honor the registration contract.
*
* These checks are the machine-readable half of the reason this repository has a
* build step at all: a bundle that escapes the factory, that carries a top-level
* `import`, or that asks the module table for something undeclared would fail in
* the page rather than here.
* @param source - the bundled artifact text.
*/
function assertBundleShape(source) {
if (!source.startsWith('// Generated by scripts/build-client.mjs')) {
throw new Error('bundle does not start with the generated-file header')
}
for (const [what, needle] of Object.entries(WRAPPER)) {
if (!source.includes(needle)) throw new Error(`bundle is missing its ${what}: ${needle}`)
}
if (!source.trimEnd().endsWith(WRAPPER.footer)) {
throw new Error('bundle does not close the registration wrapper')
}
const factoryAt = source.indexOf(WRAPPER.factory)
const reactAt = source.indexOf('require("react")')
if (reactAt === -1) throw new Error('bundle never requires react')
if (reactAt < factoryAt) throw new Error('bundle requires react outside the factory, so it would load eagerly')
if (/^\s*(?:import|export)\s/m.test(source)) {
throw new Error('bundle has a top-level import/export statement; it is not a self-contained script')
}
const requested = new Set([...source.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)].map((match) => match[1]))
for (const specifier of requested) {
if (!external.includes(specifier)) {
throw new Error(`bundle asks the module table for "${specifier}" — declare it in dsh.client.external and build-client.mjs`)
}
}
}
/**
* Bundle the browser half.
* @param options - `write: false` returns the artifact without touching the repository.
* @returns the bundled `client.js` text.
*/
export async function buildClient({ write = true } = {}) {
const { build } = await loadEsbuild()
const result = await build({
entryPoints: [entryPoint],
outfile: 'client.js',
bundle: true,
format: 'cjs',
platform: 'browser',
target: 'es2022',
charset: 'utf8',
minify: false,
legalComments: 'none',
external,
banner: { js: banner },
footer: { js: footer },
write: false,
logLevel: 'warning',
})
const produced = result.outputFiles?.[0]?.text
if (typeof produced !== 'string' || produced === '') throw new Error('esbuild produced no output')
// The repository is LF-only (see .gitattributes) and the artifact is reviewed
// as a diff, so nothing here may follow the build machine's conventions:
// esbuild emits LF, and this keeps that true if a future option changes it.
const built = produced.replace(/\r\n/g, '\n')
assertBundleShape(built)
if (write) await writeFile(targetPath, built, 'utf8')
return built
}
const invokedDirectly = process.argv[1] !== undefined
&& pathToFileURL(process.argv[1]).href === import.meta.url
if (invokedDirectly) {
const built = await buildClient()
console.log(`session-notify: client.js rebuilt from src/client (${built.length} bytes)`)
}