index.ts 16 KB

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