♻️ refactor(client): 浏览器半拆成 src 模块,client.js 改由构建产出
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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)`)
|
||||
}
|
||||
Reference in New Issue
Block a user