// 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)`) }