index.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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`), serves `/plugins/<id>/client.js` and its source
  6. * map, taps the index render to inject the boot manifest, and provides the
  7. * `clientModuleHost` service (the HMR node half's registration/notification
  8. * face).
  9. *
  10. * Scanning is incremental per package — there is no full-rescan code path.
  11. * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
  12. * the fiber's entry name dirty; a microtask flush reconciles each dirty name
  13. * against the live loader entries. The activation pass seeds the same dirty
  14. * set with all current entries and flushes synchronously, so first scan and
  15. * steady state share one implementation. Package metadata (including the
  16. * negative "not a client package" verdict) is cached per name and never
  17. * expires — plugin-set changes take effect on restart; bundle content
  18. * changes reach the graph only through
  19. * {@link ClientModuleHostService.rebuilt}.
  20. * @module @deepseek-ai/dsh-client-modules
  21. */
  22. import { createHash } from 'node:crypto'
  23. import { readFileSync } from 'node:fs'
  24. import { readFile } from 'node:fs/promises'
  25. import type { IncomingMessage, ServerResponse } from 'node:http'
  26. import { createRequire } from 'node:module'
  27. import { dirname, join } from 'node:path'
  28. import { Service } from '@deepseek-ai/cordis'
  29. import type { Context } from '@deepseek-ai/cordis'
  30. import type {} from '@deepseek-ai/cordis-plugin-loader'
  31. import type {} from '@deepseek-ai/dsh-host-webserver'
  32. import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
  33. export type {
  34. BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
  35. } from './client/manifest.ts'
  36. declare module '@deepseek-ai/cordis' {
  37. interface Context {
  38. /** The web plugin table (provided by the client-modules node half). */
  39. clientModuleHost: ClientModuleHostService
  40. }
  41. }
  42. /** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
  43. interface DshClientDeclaration {
  44. inject?: string[]
  45. platform: string
  46. /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
  47. immediately?: boolean
  48. }
  49. /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
  50. interface PkgMeta {
  51. clientPath: string
  52. inject?: string[]
  53. immediately: boolean
  54. }
  55. /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
  56. const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
  57. /** Missing built client export, retained as structured data for activation-error grouping. */
  58. class MissingClientBundleError extends Error {
  59. constructor(
  60. readonly packageName: string,
  61. readonly clientPath: string,
  62. cause: unknown,
  63. ) {
  64. super(
  65. [
  66. `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
  67. ` package: ${packageName}`,
  68. ` path: ${clientPath}`,
  69. ].join('\n'),
  70. { cause },
  71. )
  72. }
  73. }
  74. /** Activation failures grouped by actionable package-build errors and unrelated failures. */
  75. class ClientPackageCompositionError extends AggregateError {
  76. constructor(failures: Error[]) {
  77. const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
  78. const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
  79. const packageNoun = failures.length === 1 ? 'package' : 'packages'
  80. const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
  81. if (missingBundles.length > 0) {
  82. lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
  83. for (const error of missingBundles) {
  84. lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
  85. }
  86. }
  87. if (otherFailures.length > 0) {
  88. lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
  89. }
  90. super(failures, lines.join('\n'))
  91. }
  92. }
  93. /** One composed table row: the wire entry plus its bundle path. */
  94. interface WebPluginRecord {
  95. entry: WebBootEntry
  96. clientPath: string
  97. }
  98. /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
  99. function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
  100. if (value === undefined) return undefined
  101. if (typeof value !== 'object' || value === null) {
  102. throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
  103. }
  104. const decl = value as Record<string, unknown>
  105. if (typeof decl.platform !== 'string') {
  106. throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
  107. }
  108. if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
  109. throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`)
  110. }
  111. if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
  112. throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
  113. }
  114. return {
  115. platform: decl.platform,
  116. ...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
  117. ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
  118. }
  119. }
  120. /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
  121. function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
  122. if (typeof exportsField !== 'object' || exportsField === null) return undefined
  123. const client = (exportsField as Record<string, unknown>)['./client']
  124. if (client === undefined) return undefined
  125. if (typeof client === 'string') return client
  126. if (typeof client === 'object' && client !== null) {
  127. const fallback = (client as Record<string, unknown>).default
  128. if (typeof fallback === 'string') return fallback
  129. }
  130. throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
  131. }
  132. /** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
  133. function shortHash(input: string | Buffer): string {
  134. return createHash('sha1').update(input).digest('hex').slice(0, 12)
  135. }
  136. /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
  137. function graphRow(id: string, rev: string, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
  138. return {
  139. id,
  140. url: `/plugins/${id}/client.js?rev=${rev}`,
  141. rev,
  142. ...(injectEdges !== undefined ? { inject: injectEdges } : {}),
  143. ...(immediately ? { immediately: true } : {}),
  144. }
  145. }
  146. /**
  147. * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
  148. * first script in <head> (before the shell bundle reads it). `<` is escaped in
  149. * the JSON so plugin-controlled strings cannot break out of the script element.
  150. * @param html - the index.html source.
  151. * @param graph - the composed entry graph.
  152. * @returns the html with the graph script injected.
  153. */
  154. export function injectBootManifest(html: string, graph: WebBootGraph): string {
  155. const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
  156. const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
  157. const head = html.indexOf('<head>')
  158. if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
  159. // Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
  160. return `${script}${html}`
  161. }
  162. /**
  163. * The web plugin table service: incremental `dsh.client` scan + wire composition
  164. * + bundle route + index tap. Construction runs the activation scan
  165. * synchronously — a malformed declaration or missing bundle among the
  166. * already-loaded entries aggregates into one loud throw (FAILED fiber; the
  167. * boot activation audit reports it).
  168. */
  169. export class ClientModuleHostService extends Service {
  170. static inject = ['httpServer', 'loader']
  171. private readonly table = new Map<string, WebPluginRecord>()
  172. // Negative verdicts (unresolvable specifier — builtins like cordis:include,
  173. // subpath rows — or a package without a web `dsh.client` declaration) are
  174. // cached as null and never expire: plugin-set changes take effect on restart.
  175. private readonly pkgMeta = new Map<string, PkgMeta | null>()
  176. private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
  177. private readonly graphListeners = new Set<() => void>()
  178. private readonly dirty = new Set<string>()
  179. private readonly resolvePkgJson: (spec: string) => string
  180. private flushQueued = false
  181. private composed: WebBootGraph
  182. /**
  183. * Build the service: subscribe, seed, and run the activation flush.
  184. * @param ctx - plugin context carrying httpServer and loader.
  185. */
  186. constructor(ctx: Context) {
  187. super(ctx, 'clientModuleHost')
  188. // Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
  189. // whose package declares every composed plugin as a dependency). The
  190. // modules package's own URL would miss sibling packages under pnpm's
  191. // isolated node_modules.
  192. if (ctx.baseUrl === undefined) {
  193. throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
  194. }
  195. const require = createRequire(ctx.baseUrl)
  196. this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
  197. // Subscribe before seeding so a fiber arriving mid-activation lands in the
  198. // same dirty set (Set idempotence makes the overlap harmless). An entry-less
  199. // fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
  200. ctx.on('internal/plugin', (fiber) => {
  201. const entryName = fiber.entry?.options.name
  202. if (entryName === undefined) return
  203. this.dirty.add(entryName)
  204. if (this.flushQueued) return
  205. this.flushQueued = true
  206. queueMicrotask(() => {
  207. this.flushQueued = false
  208. this.flush((err) => { ctx.logger.warn(err) })
  209. })
  210. })
  211. // Activation pass: the initial scan IS the incremental path over the
  212. // current entries, flushed synchronously (nothing async between subscribe,
  213. // seed, and flush).
  214. for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
  215. this.composed = this.compose()
  216. const failures: Error[] = []
  217. this.flush(err => failures.push(err))
  218. if (failures.length > 0) {
  219. throw new ClientPackageCompositionError(failures)
  220. }
  221. ctx.effect(
  222. () => ctx.httpServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
  223. 'client-modules: bundle route',
  224. )
  225. ctx.effect(
  226. () => ctx.httpServer.tapIndex(html => injectBootManifest(html, this.composed)),
  227. 'client-modules: boot manifest injection',
  228. )
  229. }
  230. /**
  231. * Current composed entry graph (stable object between changes).
  232. * @returns the graph served as `window.__DSH_BOOT__`.
  233. */
  234. graph(): WebBootGraph {
  235. return this.composed
  236. }
  237. /**
  238. * Absolute path of an entry's client bundle.
  239. * @param id - entry id (package name).
  240. * @returns the path, or undefined for an unknown id.
  241. */
  242. clientPath(id: string): string | undefined {
  243. return this.table.get(id)?.clientPath
  244. }
  245. /**
  246. * Re-hash one bundle (the HMR watch's registration hook — the only entry
  247. * point through which bundle content changes reach the graph).
  248. * @param id - entry id (package name).
  249. * @returns the new rev, or undefined for an unknown id.
  250. */
  251. rebuilt(id: string): string | undefined {
  252. const record = this.table.get(id)
  253. if (record === undefined) return undefined
  254. const rev = shortHash(readFileSync(record.clientPath))
  255. if (rev === record.entry.rev) return rev
  256. record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
  257. this.composed = this.compose()
  258. for (const notify of this.rebuildListeners) {
  259. // Containment: rebuilt() runs inside the HMR watch callback — a
  260. // throwing subscriber must not kill the poll or skip later subscribers.
  261. try {
  262. notify(id, rev)
  263. } catch (error) {
  264. this.ctx.logger.error(error)
  265. }
  266. }
  267. this.notifyGraphChanged()
  268. return rev
  269. }
  270. /**
  271. * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
  272. * @param listener - receives the entry id and its new bundle rev.
  273. * @returns the unsubscriber.
  274. */
  275. onRebuilt(listener: (id: string, rev: string) => void): () => void {
  276. this.rebuildListeners.add(listener)
  277. return () => { this.rebuildListeners.delete(listener) }
  278. }
  279. /**
  280. * Fires after any flush that recomposed the graph (row added/removed, or a
  281. * rebuilt rev change). Pull model: listeners re-read {@link graph}.
  282. * @param listener - notified with no payload.
  283. * @returns the unsubscriber.
  284. */
  285. onGraphChanged(listener: () => void): () => void {
  286. this.graphListeners.add(listener)
  287. return () => { this.graphListeners.delete(listener) }
  288. }
  289. private compose(): WebBootGraph {
  290. const entries = [...this.table.values()].map(record => record.entry)
  291. return { rev: shortHash(JSON.stringify(entries)), entries }
  292. }
  293. private notifyGraphChanged(): void {
  294. for (const listener of this.graphListeners) {
  295. // A throwing subscriber must not skip later subscribers (or escape into
  296. // whatever triggered the flush — possibly an fs.watchFile callback).
  297. try {
  298. listener()
  299. } catch (error) {
  300. this.ctx.logger.error(error)
  301. }
  302. }
  303. }
  304. private resolveMeta(pkgName: string): PkgMeta | null {
  305. const cached = this.pkgMeta.get(pkgName)
  306. if (cached !== undefined) return cached
  307. let pkgPath: string
  308. try {
  309. pkgPath = this.resolvePkgJson(pkgName)
  310. } catch {
  311. // Not a resolvable package root: loader builtins (cordis:include) and
  312. // subpath entries (…/gateway) land here — permanently not a client row.
  313. this.pkgMeta.set(pkgName, null)
  314. return null
  315. }
  316. const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
  317. const dsh = pkg.dsh
  318. const decl = parseDshClient(
  319. pkgName,
  320. dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
  321. )
  322. if (decl === undefined || decl.platform !== 'web') {
  323. this.pkgMeta.set(pkgName, null)
  324. return null
  325. }
  326. const clientRel = clientExportOf(pkgName, pkg.exports)
  327. if (clientRel === undefined) {
  328. throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
  329. }
  330. const meta: PkgMeta = {
  331. clientPath: join(dirname(pkgPath), clientRel),
  332. ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
  333. immediately: decl.immediately === true,
  334. }
  335. this.pkgMeta.set(pkgName, meta)
  336. return meta
  337. }
  338. /**
  339. * Read the activation-time bundle revision.
  340. * @param pkgName - package that declares the client bundle.
  341. * @param clientPath - absolute path of the built client artifact.
  342. * @returns the bundle content's short hash for use as its revision.
  343. * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
  344. */
  345. private initialBundleRevision(pkgName: string, clientPath: string): string {
  346. try {
  347. return shortHash(readFileSync(clientPath))
  348. } catch (error) {
  349. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  350. throw new MissingClientBundleError(pkgName, clientPath, error)
  351. }
  352. }
  353. /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
  354. private processOne(entryName: string): boolean {
  355. let qualifies = false
  356. for (const entry of this.ctx.loader.entries()) {
  357. if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
  358. qualifies = true
  359. break
  360. }
  361. }
  362. if (!qualifies) return this.table.delete(entryName)
  363. if (this.table.has(entryName)) return false
  364. const meta = this.resolveMeta(entryName)
  365. if (meta === null) return false
  366. // The rev rides the row from here on: a fiber restart reuses the row (and
  367. // its rev) untouched; only rebuilt() re-reads the bundle.
  368. const rev = this.initialBundleRevision(entryName, meta.clientPath)
  369. this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
  370. return true
  371. }
  372. private flush(onError: (err: Error) => void): void {
  373. let changed = false
  374. for (const entryName of [...this.dirty]) {
  375. this.dirty.delete(entryName)
  376. try {
  377. if (this.processOne(entryName)) changed = true
  378. } catch (error) {
  379. // Steady state: one broken package must not poison the others; the
  380. // activation pass aggregates these into a loud throw instead.
  381. onError(error instanceof Error ? error : new Error(String(error)))
  382. }
  383. }
  384. if (changed) {
  385. this.composed = this.compose()
  386. this.notifyGraphChanged()
  387. }
  388. }
  389. private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
  390. if (req.method !== 'GET' && req.method !== 'HEAD') {
  391. res.writeHead(405)
  392. res.end()
  393. return
  394. }
  395. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
  396. const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
  397. // The id may contain a scope slash. Anything else under /plugins (including
  398. // /plugins/events when the HMR row is absent) is an unknown resource.
  399. const prefix = '/plugins/'
  400. const mapSuffix = '/client.js.map'
  401. const bundleSuffix = '/client.js'
  402. const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
  403. const suffix = isSourceMap ? mapSuffix : bundleSuffix
  404. const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
  405. ? this.clientPath(pathname.slice(prefix.length, -suffix.length))
  406. : undefined
  407. const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
  408. if (path === undefined) {
  409. res.writeHead(404)
  410. res.end()
  411. return
  412. }
  413. try {
  414. const body = await readFile(path)
  415. res.writeHead(200, {
  416. 'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
  417. 'cache-control': 'no-cache',
  418. })
  419. res.end(body)
  420. } catch {
  421. // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
  422. res.writeHead(404)
  423. res.end()
  424. }
  425. }
  426. }
  427. export default ClientModuleHostService