index.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. /**
  2. * Node half of the client module system (`dsh.client` dual-face package): scans
  3. * the host Loader's entries for packages declaring `dsh.client`, composes the
  4. * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
  5. * in `./client/manifest.ts`) in module-graph order, serves
  6. * `/plugins/<id>/client.js` and its source map, taps the index render to
  7. * inject the boot manifest plus the parser-blocking bootstrap preloads, and
  8. * provides the `clientModuleHost` service (the HMR node half's
  9. * registration/notification face).
  10. *
  11. * Scanning is incremental per package — there is no full-rescan code path.
  12. * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
  13. * the fiber's entry name dirty; a microtask flush reconciles each dirty name
  14. * against the live loader entries. The activation pass seeds the same dirty
  15. * set with all current entries and flushes synchronously, so first scan and
  16. * steady state share one implementation. Package metadata (including the
  17. * negative "not a client package" verdict) is cached per name and never
  18. * expires — plugin-set changes take effect on restart; bundle content
  19. * changes reach the graph only through
  20. * {@link ClientModuleRegistry.rebuilt}.
  21. * @module @deepseek-ai/dsh-client-modules
  22. */
  23. import { createHash } from 'node:crypto'
  24. import { readFileSync } from 'node:fs'
  25. import { readFile } from 'node:fs/promises'
  26. import type { IncomingMessage, ServerResponse } from 'node:http'
  27. import { createRequire } from 'node:module'
  28. import { dirname, join } from 'node:path'
  29. import { Service } from '@deepseek-ai/cordis'
  30. import type { Context } from '@deepseek-ai/cordis'
  31. import type {} from '@deepseek-ai/cordis-plugin-loader'
  32. import type {} from '@deepseek-ai/dsh-host-webserver'
  33. import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
  34. import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
  35. export { stripClientSuffix } from './client/manifest.ts'
  36. export type {
  37. BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
  38. } from './client/manifest.ts'
  39. declare module '@deepseek-ai/cordis' {
  40. interface Context {
  41. /** The web plugin table (provided by the client-modules node half). */
  42. clientModules: ClientModuleRegistry
  43. }
  44. }
  45. /** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
  46. interface DshClientDeclaration {
  47. inject?: string[]
  48. platform: string
  49. /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
  50. immediately?: boolean
  51. /**
  52. * Exact module-table requests beyond the implicit client baseline. Any
  53. * specifier is valid, including subpaths such as `<pkg>/client`; each
  54. * importing package declares its own exceptional requests. A type-only
  55. * import is not a request because the transform erases it before resolution.
  56. * Absent means the package uses only the baseline externals.
  57. */
  58. external?: string[]
  59. }
  60. /** The declared fields a graph row carries, normalized (absent array declarations become empty). */
  61. interface WebBootRowFields {
  62. inject?: string[]
  63. /** Module specifiers the package requests from the module table. */
  64. external: string[]
  65. immediately: boolean
  66. }
  67. /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
  68. interface PkgMeta extends WebBootRowFields {
  69. clientPath: string
  70. }
  71. /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
  72. const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
  73. /** Missing built client export, retained as structured data for activation-error grouping. */
  74. class MissingClientBundleError extends Error {
  75. constructor(
  76. readonly packageName: string,
  77. readonly clientPath: string,
  78. cause: unknown,
  79. ) {
  80. super(
  81. [
  82. `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
  83. ` package: ${packageName}`,
  84. ` path: ${clientPath}`,
  85. ].join('\n'),
  86. { cause },
  87. )
  88. }
  89. }
  90. /** Activation failures grouped by actionable package-build errors and unrelated failures. */
  91. class ClientPackageCompositionError extends AggregateError {
  92. constructor(failures: Error[]) {
  93. const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
  94. const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
  95. const packageNoun = failures.length === 1 ? 'package' : 'packages'
  96. const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
  97. if (missingBundles.length > 0) {
  98. lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
  99. for (const error of missingBundles) {
  100. lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
  101. }
  102. }
  103. if (otherFailures.length > 0) {
  104. lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
  105. }
  106. super(failures, lines.join('\n'))
  107. }
  108. }
  109. /** One composed table row: the wire entry plus the resolved package metadata behind it. */
  110. interface WebPluginRecord {
  111. entry: WebBootEntry
  112. meta: PkgMeta
  113. }
  114. /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
  115. function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
  116. if (value === undefined) return undefined
  117. if (typeof value !== 'object' || value === null) {
  118. throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
  119. }
  120. const decl = value as Record<string, unknown>
  121. if (typeof decl.platform !== 'string') {
  122. throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
  123. }
  124. const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
  125. const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
  126. if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
  127. throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
  128. }
  129. return {
  130. platform: decl.platform,
  131. ...(inject !== undefined ? { inject } : {}),
  132. ...(external !== undefined ? { external } : {}),
  133. ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
  134. }
  135. }
  136. /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
  137. function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
  138. if (typeof exportsField !== 'object' || exportsField === null) return undefined
  139. const client = (exportsField as Record<string, unknown>)['./client']
  140. if (client === undefined) return undefined
  141. if (typeof client === 'string') return client
  142. if (typeof client === 'object' && client !== null) {
  143. const fallback = (client as Record<string, unknown>).default
  144. if (typeof fallback === 'string') return fallback
  145. }
  146. throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
  147. }
  148. /** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
  149. function shortHash(input: string | Buffer): string {
  150. return createHash('sha1').update(input).digest('hex').slice(0, 12)
  151. }
  152. /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
  153. function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
  154. return {
  155. id,
  156. url: `/plugins/${id}/client.js?rev=${rev}`,
  157. rev,
  158. ...(fields.inject !== undefined ? { inject: fields.inject } : {}),
  159. ...(fields.immediately ? { immediately: true } : {}),
  160. ...(fields.external.length > 0 ? { external: fields.external } : {}),
  161. }
  162. }
  163. /**
  164. * Order composed rows so every requested dynamic package precedes its
  165. * consumers. An `external` specifier is either the package row it names
  166. * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
  167. * graph edge.
  168. * @param entries - composed rows in scan order.
  169. * @returns the same rows reordered; scan order breaks every tie.
  170. * @throws {Error} when a row requests itself or when the module graph has a
  171. * cycle; the message lists the packages on it.
  172. */
  173. export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[] {
  174. const rowsById = new Map<string, WebBootEntry>()
  175. for (const entry of entries) rowsById.set(entry.id, entry)
  176. const ordered: WebBootEntry[] = []
  177. const placed = new Set<string>()
  178. const open: string[] = []
  179. const visit = (entry: WebBootEntry): void => {
  180. if (placed.has(entry.id)) return
  181. const cycleStart = open.indexOf(entry.id)
  182. if (cycleStart !== -1) {
  183. throw new Error(
  184. `client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(' -> ')} `
  185. + '— a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports',
  186. )
  187. }
  188. open.push(entry.id)
  189. for (const name of entry.external ?? []) {
  190. const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name))
  191. if (dependency === entry) {
  192. throw new Error(
  193. `client-modules: "${entry.id}" requests module "${name}" that it answers itself `
  194. + '— a row must not declare its own package in dsh.client.external',
  195. )
  196. }
  197. if (dependency !== undefined) visit(dependency)
  198. }
  199. open.pop()
  200. placed.add(entry.id)
  201. ordered.push(entry)
  202. }
  203. for (const entry of entries) visit(entry)
  204. return ordered
  205. }
  206. /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
  207. const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
  208. /** Dynamic package whose ordinary client bundle must be registered before plugin boot starts. */
  209. const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
  210. /** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
  211. const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
  212. /** Escape a graph URL before placing it in a quoted HTML attribute. */
  213. function escapeHtmlAttribute(value: string): string {
  214. return value
  215. .replaceAll('&', '&amp;')
  216. .replaceAll('"', '&quot;')
  217. .replaceAll('<', '&lt;')
  218. .replaceAll('>', '&gt;')
  219. }
  220. /**
  221. * Inject the boot protocol into index.html. The inline registration queue precedes
  222. * blocking classic scripts for modules' and runtime's ordinary
  223. * `lib/client.js` artifacts. Its `create()` method materializes the modules
  224. * bundle, delegates construction to that bundle, and leaves the same facade
  225. * in live-registration mode. The graph script follows before the shell reads
  226. * it. `<` is escaped in JSON so a plugin-controlled string cannot break out
  227. * of the script element.
  228. * @param html - the index.html source.
  229. * @param graph - the composed entry graph.
  230. * @returns the html with the graph script injected.
  231. */
  232. export function injectBootManifest(html: string, graph: WebBootGraph): string {
  233. const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
  234. const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
  235. const queue = `<script>(()=>{
  236. const pendingQueue=[]
  237. window.__ModuleLoader__={
  238. mode:"queue",
  239. pendingQueue,
  240. load(registration){pendingQueue.push(registration)},
  241. create(options){
  242. if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
  243. const index=pendingQueue.findIndex(registration=>registration.id===${bootstrapId})
  244. const registration=pendingQueue[index]
  245. if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
  246. pendingQueue.splice(index,1)
  247. const exports=registration.factory(specifier=>{
  248. throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
  249. })
  250. if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
  251. throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
  252. }
  253. return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
  254. }
  255. }
  256. })()</script>`
  257. const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
  258. .filter((entry): entry is WebBootEntry => entry !== undefined)
  259. .map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
  260. .join('')
  261. const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
  262. const head = html.indexOf('<head>')
  263. if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
  264. // Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
  265. return `${script}${html}`
  266. }
  267. /**
  268. * The web plugin table service: incremental `dsh.client` scan + wire composition
  269. * + bundle route + index tap. Construction runs the activation scan
  270. * synchronously — a malformed declaration or missing bundle among the
  271. * already-loaded entries aggregates into one loud throw (FAILED fiber; the
  272. * boot activation audit reports it).
  273. */
  274. export class ClientModuleRegistry extends Service {
  275. static inject = ['webServer', 'loader']
  276. private readonly table = new Map<string, WebPluginRecord>()
  277. // Negative verdicts (unresolvable specifier — builtins like cordis:include,
  278. // subpath rows — or a package without a web `dsh.client` declaration) are
  279. // cached as null and never expire: plugin-set changes take effect on restart.
  280. private readonly pkgMeta = new Map<string, PkgMeta | null>()
  281. private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
  282. private readonly graphListeners = new Set<() => void>()
  283. private readonly dirty = new Set<string>()
  284. private readonly resolvePkgJson: (spec: string) => string
  285. private flushQueued = false
  286. private composed: WebBootGraph
  287. /**
  288. * Build the service: subscribe, seed, and run the activation flush.
  289. * @param ctx - plugin context carrying webServer and loader.
  290. */
  291. constructor(ctx: Context) {
  292. super(ctx, 'clientModules')
  293. // Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
  294. // whose package declares every composed plugin as a dependency). The
  295. // modules package's own URL would miss sibling packages under pnpm's
  296. // isolated node_modules.
  297. if (ctx.baseUrl === undefined) {
  298. throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
  299. }
  300. const require = createRequire(ctx.baseUrl)
  301. this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
  302. // Subscribe before seeding so a fiber arriving mid-activation lands in the
  303. // same dirty set (Set idempotence makes the overlap harmless). An entry-less
  304. // fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
  305. ctx.on('internal/plugin', (fiber) => {
  306. const entryName = fiber.entry?.options.name
  307. if (entryName === undefined) return
  308. this.dirty.add(entryName)
  309. if (this.flushQueued) return
  310. this.flushQueued = true
  311. queueMicrotask(() => {
  312. this.flushQueued = false
  313. this.flush((err) => { ctx.logger.warn(err) })
  314. })
  315. })
  316. // Activation pass: the initial scan IS the incremental path over the
  317. // current entries, flushed synchronously (nothing async between subscribe,
  318. // seed, and flush).
  319. for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
  320. this.composed = this.compose()
  321. const failures: Error[] = []
  322. this.flush(err => failures.push(err))
  323. if (failures.length > 0) {
  324. throw new ClientPackageCompositionError(failures)
  325. }
  326. ctx.effect(
  327. () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
  328. 'client-modules: bundle route',
  329. )
  330. ctx.effect(
  331. () => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)),
  332. 'client-modules: boot manifest injection',
  333. )
  334. }
  335. /**
  336. * Current composed entry graph (stable object between changes).
  337. * @returns the graph served as `window.__DSH_BOOT__`.
  338. */
  339. graph(): WebBootGraph {
  340. return this.composed
  341. }
  342. /**
  343. * Absolute path of an entry's client bundle.
  344. * @param id - entry id (package name).
  345. * @returns the path, or undefined for an unknown id.
  346. */
  347. clientPath(id: string): string | undefined {
  348. return this.table.get(id)?.meta.clientPath
  349. }
  350. /**
  351. * Re-hash one bundle (the HMR watch's registration hook — the only entry
  352. * point through which bundle content changes reach the graph).
  353. * @param id - entry id (package name).
  354. * @returns the new rev, or undefined for an unknown id.
  355. */
  356. rebuilt(id: string): string | undefined {
  357. const record = this.table.get(id)
  358. if (record === undefined) return undefined
  359. const rev = shortHash(readFileSync(record.meta.clientPath))
  360. if (rev === record.entry.rev) return rev
  361. record.entry = graphRow(id, rev, record.meta)
  362. this.composed = this.compose()
  363. for (const notify of this.rebuildListeners) {
  364. // Containment: rebuilt() runs inside the HMR watch callback — a
  365. // throwing subscriber must not kill the poll or skip later subscribers.
  366. try {
  367. notify(id, rev)
  368. } catch (error) {
  369. this.ctx.logger.error(error)
  370. }
  371. }
  372. this.notifyGraphChanged()
  373. return rev
  374. }
  375. /**
  376. * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
  377. * @param listener - receives the entry id and its new bundle rev.
  378. * @returns the unsubscriber.
  379. */
  380. onRebuilt(listener: (id: string, rev: string) => void): () => void {
  381. this.rebuildListeners.add(listener)
  382. return () => { this.rebuildListeners.delete(listener) }
  383. }
  384. /**
  385. * Fires after any flush that recomposed the graph (row added/removed, or a
  386. * rebuilt rev change). Pull model: listeners re-read {@link graph}.
  387. * @param listener - notified with no payload.
  388. * @returns the unsubscriber.
  389. */
  390. onGraphChanged(listener: () => void): () => void {
  391. this.graphListeners.add(listener)
  392. return () => { this.graphListeners.delete(listener) }
  393. }
  394. private compose(): WebBootGraph {
  395. const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
  396. return { rev: shortHash(JSON.stringify(entries)), entries }
  397. }
  398. private notifyGraphChanged(): void {
  399. for (const listener of this.graphListeners) {
  400. // A throwing subscriber must not skip later subscribers (or escape into
  401. // whatever triggered the flush — possibly an fs.watchFile callback).
  402. try {
  403. listener()
  404. } catch (error) {
  405. this.ctx.logger.error(error)
  406. }
  407. }
  408. }
  409. private resolveMeta(pkgName: string): PkgMeta | null {
  410. const cached = this.pkgMeta.get(pkgName)
  411. if (cached !== undefined) return cached
  412. let pkgPath: string
  413. try {
  414. pkgPath = this.resolvePkgJson(pkgName)
  415. } catch {
  416. // Not a resolvable package root: loader builtins (cordis:include) and
  417. // subpath entries (…/gateway) land here — permanently not a client row.
  418. this.pkgMeta.set(pkgName, null)
  419. return null
  420. }
  421. const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
  422. const dsh = pkg.dsh
  423. const decl = parseDshClient(
  424. pkgName,
  425. dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
  426. )
  427. if (decl === undefined || decl.platform !== 'web') {
  428. this.pkgMeta.set(pkgName, null)
  429. return null
  430. }
  431. const clientRel = clientExportOf(pkgName, pkg.exports)
  432. if (clientRel === undefined) {
  433. throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
  434. }
  435. const meta: PkgMeta = {
  436. clientPath: join(dirname(pkgPath), clientRel),
  437. ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
  438. external: decl.external ?? [],
  439. immediately: decl.immediately === true,
  440. }
  441. this.pkgMeta.set(pkgName, meta)
  442. return meta
  443. }
  444. /**
  445. * Read the activation-time bundle revision.
  446. * @param pkgName - package that declares the client bundle.
  447. * @param clientPath - absolute path of the built client artifact.
  448. * @returns the bundle content's short hash for use as its revision.
  449. * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
  450. */
  451. private initialBundleRevision(pkgName: string, clientPath: string): string {
  452. try {
  453. return shortHash(readFileSync(clientPath))
  454. } catch (error) {
  455. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  456. throw new MissingClientBundleError(pkgName, clientPath, error)
  457. }
  458. }
  459. /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
  460. private processOne(entryName: string): boolean {
  461. let qualifies = false
  462. for (const entry of this.ctx.loader.entries()) {
  463. if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
  464. qualifies = true
  465. break
  466. }
  467. }
  468. if (!qualifies) return this.table.delete(entryName)
  469. if (this.table.has(entryName)) return false
  470. const meta = this.resolveMeta(entryName)
  471. if (meta === null) return false
  472. // The rev rides the row from here on: a fiber restart reuses the row (and
  473. // its rev) untouched; only rebuilt() re-reads the bundle.
  474. const rev = this.initialBundleRevision(entryName, meta.clientPath)
  475. this.table.set(entryName, { entry: graphRow(entryName, rev, meta), meta })
  476. return true
  477. }
  478. private flush(onError: (err: Error) => void): void {
  479. let changed = false
  480. for (const entryName of [...this.dirty]) {
  481. this.dirty.delete(entryName)
  482. try {
  483. if (this.processOne(entryName)) changed = true
  484. } catch (error) {
  485. // Steady state: one broken package must not poison the others; the
  486. // activation pass aggregates these into a loud throw instead.
  487. onError(error instanceof Error ? error : new Error(String(error)))
  488. }
  489. }
  490. if (!changed) return
  491. let composed: WebBootGraph
  492. try {
  493. composed = this.compose()
  494. } catch (error) {
  495. // An unorderable module graph is a property of the whole table, not of
  496. // the arriving package, so it surfaces here: aggregated into the
  497. // activation throw, or warned in steady state while the last orderable
  498. // graph stays served.
  499. onError(error as Error)
  500. return
  501. }
  502. this.composed = composed
  503. this.notifyGraphChanged()
  504. }
  505. private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
  506. if (req.method !== 'GET' && req.method !== 'HEAD') {
  507. res.writeHead(405)
  508. res.end()
  509. return
  510. }
  511. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
  512. const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
  513. // The id may contain a scope slash. Anything else under /plugins (including
  514. // /plugins/events when the HMR row is absent) is an unknown resource.
  515. const prefix = '/plugins/'
  516. const mapSuffix = '/client.js.map'
  517. const bundleSuffix = '/client.js'
  518. const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
  519. const suffix = isSourceMap ? mapSuffix : bundleSuffix
  520. const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
  521. ? this.clientPath(pathname.slice(prefix.length, -suffix.length))
  522. : undefined
  523. const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
  524. if (path === undefined) {
  525. res.writeHead(404)
  526. res.end()
  527. return
  528. }
  529. try {
  530. const body = await readFile(path)
  531. res.writeHead(200, {
  532. 'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
  533. 'cache-control': 'no-cache',
  534. })
  535. res.end(body)
  536. } catch {
  537. // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
  538. res.writeHead(404)
  539. res.end()
  540. }
  541. }
  542. }
  543. export default ClientModuleRegistry