index.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896
  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 one-or-more-plugin
  6. * combo scripts plus their source maps,
  7. * contributes the registration facade, application preloads, bootstrap scripts,
  8. * and graph to the webserver's index injection table, and provides the
  9. * `clientModuleHost` service (the HMR node half's registration/notification
  10. * face).
  11. *
  12. * Scanning is incremental per package — there is no full-rescan code path.
  13. * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
  14. * the fiber's entry name dirty; a microtask flush reconciles each dirty name
  15. * against the live loader entries. The activation pass seeds the same dirty
  16. * set with all current entries and flushes synchronously, so first scan and
  17. * steady state share one implementation. Package metadata (including the
  18. * negative "not a client package" verdict) is cached per name and never
  19. * expires — plugin-set changes take effect on restart; bundle content
  20. * changes reach the graph only through
  21. * {@link ClientModuleRegistry.rebuilt}.
  22. * @module @deepseek-ai/dsh-client-modules
  23. */
  24. import { createHash, randomBytes } from 'node:crypto'
  25. import { readFileSync, statSync, type Stats } from 'node:fs'
  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 { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
  33. import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
  34. import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
  35. export { stripClientSuffix } from './client/manifest.ts'
  36. export type {
  37. BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, 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 registration barrier; absent rows still ride the shared application batch. */
  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. /** Filesystem baseline captured before a client artifact snapshot is read. */
  68. export interface ClientArtifactBaseline {
  69. /** Absolute path of the client bundle. */
  70. readonly path: string
  71. /** Bundle modification time in milliseconds. */
  72. readonly mtimeMs: number
  73. /** Bundle size in bytes. */
  74. readonly size: number
  75. /** Source-map modification time, or null when no map was observable. */
  76. readonly mapMtimeMs: number | null
  77. /** Source-map size in bytes, or null when no map was observable. */
  78. readonly mapSize: number | null
  79. }
  80. /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
  81. interface PkgMeta extends WebBootRowFields {
  82. clientPath: string
  83. }
  84. /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
  85. const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
  86. /** Missing built client export, retained as structured data for activation-error grouping. */
  87. class MissingClientBundleError extends Error {
  88. constructor(
  89. readonly packageName: string,
  90. readonly clientPath: string,
  91. cause: unknown,
  92. ) {
  93. super(
  94. [
  95. `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
  96. ` package: ${packageName}`,
  97. ` path: ${clientPath}`,
  98. ].join('\n'),
  99. { cause },
  100. )
  101. }
  102. }
  103. /** Activation failures grouped by actionable package-build errors and unrelated failures. */
  104. class ClientPackageCompositionError extends AggregateError {
  105. constructor(failures: Error[]) {
  106. const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
  107. const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
  108. const packageNoun = failures.length === 1 ? 'package' : 'packages'
  109. const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
  110. if (missingBundles.length > 0) {
  111. lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
  112. for (const error of missingBundles) {
  113. lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
  114. }
  115. }
  116. if (otherFailures.length > 0) {
  117. lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
  118. }
  119. super(failures, lines.join('\n'))
  120. }
  121. }
  122. /** One composed table row: the wire entry plus the resolved package metadata behind it. */
  123. interface WebPluginRecord {
  124. entry: WebBootEntry
  125. meta: PkgMeta
  126. /** Exact build artifact included in the startup batches. */
  127. bundle: Buffer
  128. /** Pre-read filesystem baseline handed to the HMR watcher. */
  129. baseline: ClientArtifactBaseline
  130. /** Optional authored source map snapshot; generated-file identity mapping is the fallback. */
  131. sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
  132. }
  133. /** Fields shared by every generated combo response. */
  134. interface ComboArtifactBase {
  135. url: string
  136. rev: string
  137. entries: string[]
  138. script: Buffer
  139. }
  140. /** One generated combo response over an ordered list of plugin resources. */
  141. interface ComboArtifact extends ComboArtifactBase {
  142. sourceMap: Buffer
  143. sourceMapUrl: string
  144. }
  145. /** One generated initial-load response and its wire descriptor. */
  146. type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
  147. /** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
  148. const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
  149. /** Generated request URLs stay below conservative browser and intermediary request-target limits. */
  150. const MAX_COMBO_URL_BYTES = 3 * 1024
  151. const HASH_REVISION_LENGTH = 12
  152. const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
  153. /** Source-map trailer emitted by tsdown at the end of every client bundle. */
  154. const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
  155. /** Debugger source name appended to page bundles in the WebWorker image. */
  156. const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
  157. /** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
  158. function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
  159. if (value === undefined) return undefined
  160. if (typeof value !== 'object' || value === null) {
  161. throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
  162. }
  163. const decl = value as Record<string, unknown>
  164. if (typeof decl.platform !== 'string') {
  165. throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
  166. }
  167. const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
  168. const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
  169. if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
  170. throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
  171. }
  172. return {
  173. platform: decl.platform,
  174. ...(inject !== undefined ? { inject } : {}),
  175. ...(external !== undefined ? { external } : {}),
  176. ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
  177. }
  178. }
  179. /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
  180. function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
  181. if (typeof exportsField !== 'object' || exportsField === null) return undefined
  182. const client = (exportsField as Record<string, unknown>)['./client']
  183. if (client === undefined) return undefined
  184. if (typeof client === 'string') return client
  185. if (typeof client === 'object' && client !== null) {
  186. const fallback = (client as Record<string, unknown>).default
  187. if (typeof fallback === 'string') return fallback
  188. }
  189. throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
  190. }
  191. /** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
  192. function shortHash(input: string | Buffer): string {
  193. return createHash('sha1').update(input).digest('hex').slice(0, HASH_REVISION_LENGTH)
  194. }
  195. /** Hash several response fields without allowing bytes to move across field boundaries. */
  196. function framedHash(domain: string, parts: readonly Buffer[]): string {
  197. const hash = createHash('sha1').update(domain).update('\0')
  198. for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part)
  199. return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
  200. }
  201. /** Hash every artifact input served after HMR observes one plugin change. */
  202. function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
  203. return framedHash('plugin-artifact', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
  204. }
  205. /** Address one ordered plugin-file list through the shared combo route. */
  206. function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
  207. const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
  208. return `/plugins/??${resources}&rev=${rev}`
  209. }
  210. /** Measure the longer map-form URL used to partition a startup resource list. */
  211. function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
  212. return Buffer.byteLength(comboUrl(
  213. records.map(record => record.entry.id),
  214. COMBO_REVISION_PLACEHOLDER,
  215. true,
  216. ))
  217. }
  218. /** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
  219. function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRecord[][] {
  220. const chunks: WebPluginRecord[][] = []
  221. let current: WebPluginRecord[] = []
  222. for (const record of records) {
  223. const candidate = [...current, record]
  224. if (projectedComboUrlBytes(candidate) <= MAX_COMBO_URL_BYTES) {
  225. current = candidate
  226. continue
  227. }
  228. if (current.length === 0) {
  229. throw new Error(
  230. `client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
  231. )
  232. }
  233. chunks.push(current)
  234. current = [record]
  235. if (projectedComboUrlBytes(current) > MAX_COMBO_URL_BYTES) {
  236. throw new Error(
  237. `client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
  238. )
  239. }
  240. }
  241. if (current.length > 0) chunks.push(current)
  242. return chunks
  243. }
  244. /** Executable source plus the generated-file name used when no authored map exists. */
  245. interface ComboSource {
  246. source: string
  247. fallbackSource: string
  248. }
  249. /** Remove bundle-local debug directives and retain their stable generated-file name. */
  250. function comboSource(record: WebPluginRecord): ComboSource {
  251. let source = record.bundle.toString('utf8')
  252. const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1]
  253. source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
  254. if (!source.endsWith('\n')) source += '\n'
  255. const fallbackSource = sourceUrl === undefined
  256. ? `/plugins/${record.entry.id}/client.js`
  257. : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
  258. return { source, fallbackSource }
  259. }
  260. /** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
  261. function comboScript(input: string, sourceMapUrl?: string): Buffer {
  262. return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
  263. }
  264. /** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
  265. function sourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
  266. let body: Buffer
  267. try {
  268. body = readFileSync(`${clientPath}.map`)
  269. } catch (error) {
  270. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
  271. throw error
  272. }
  273. const value = JSON.parse(body.toString('utf8')) as unknown
  274. const parsed = typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
  275. if (
  276. parsed === undefined
  277. || parsed.version !== 3
  278. || !Array.isArray(parsed.sources)
  279. || parsed.sources.some(source => typeof source !== 'string')
  280. || !Array.isArray(parsed.names)
  281. || parsed.names.some(name => typeof name !== 'string')
  282. || typeof parsed.mappings !== 'string'
  283. ) {
  284. throw new Error(`client-modules: ${clientPath}.map is not a regular Source Map v3 object`)
  285. }
  286. return { body, parsed }
  287. }
  288. /** Count generated lines while assembling indexed-map section offsets. */
  289. function newlineCount(value: string): number {
  290. let count = 0
  291. for (const char of value) if (char === '\n') count += 1
  292. return count
  293. }
  294. /** Resolve section sources against their original per-plugin map URL before combo relocation. */
  295. function comboSectionMap(record: WebPluginRecord): Record<string, unknown> {
  296. const original = record.sourceMap?.parsed
  297. /* v8 ignore next -- callers add sections only for records with a source map. */
  298. if (original === undefined) throw new Error(`client-modules: source map missing for ${record.entry.id}`)
  299. const sourcePaths = original.sources as string[]
  300. const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
  301. const base = new URL(`/plugins/${record.entry.id}/client.js.map`, 'http://dsh.invalid')
  302. const relocated = sourcePaths.map((source) => {
  303. const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
  304. const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
  305. return resolved.origin === base.origin
  306. ? `${resolved.pathname}${resolved.search}${resolved.hash}`
  307. : resolved.href
  308. })
  309. const section: Record<string, unknown> = { ...original, sources: relocated }
  310. delete section.sourceRoot
  311. return section
  312. }
  313. /** Map each generated line to the same line in a bundled JavaScript source. */
  314. function identitySectionMap(source: string, sourceUrl: string): Record<string, unknown> {
  315. const mappings = Array.from({ length: newlineCount(source) }, (_, index) => index === 0 ? 'AAAA' : 'AACA')
  316. .join(';')
  317. return {
  318. version: 3,
  319. names: [],
  320. sources: [sourceUrl],
  321. sourcesContent: [source],
  322. mappings,
  323. }
  324. }
  325. /** Concatenate one or more factory registrations and compose their maps as indexed sections. */
  326. function buildCombo(records: readonly WebPluginRecord[], revision?: string): ComboArtifact {
  327. let source = ''
  328. const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
  329. let line = 0
  330. for (const record of records) {
  331. const prepared = comboSource(record)
  332. const section = record.sourceMap === undefined
  333. ? identitySectionMap(prepared.source, prepared.fallbackSource)
  334. : comboSectionMap(record)
  335. sections.push({ offset: { line, column: 0 }, map: section })
  336. const bundle = `${prepared.source};\n`
  337. source += bundle
  338. line += newlineCount(bundle)
  339. }
  340. const sourceMap = Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
  341. const sourceBytes = Buffer.from(source)
  342. const rev = revision ?? framedHash('combo', [sourceBytes, sourceMap])
  343. const entries = records.map(record => record.entry.id)
  344. const url = comboUrl(entries, rev)
  345. const sourceMapUrl = comboUrl(entries, rev, true)
  346. return { url, rev, entries, script: comboScript(source, sourceMapUrl), sourceMap, sourceMapUrl }
  347. }
  348. /** Add initial-load scheduling metadata to a combo artifact. */
  349. function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
  350. const artifact = buildCombo(records)
  351. return {
  352. ...artifact,
  353. descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
  354. }
  355. }
  356. /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
  357. function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
  358. return {
  359. id,
  360. url: comboUrl([id], rev),
  361. rev,
  362. ...(fields.inject !== undefined ? { inject: fields.inject } : {}),
  363. ...(fields.immediately ? { immediately: true } : {}),
  364. ...(fields.external.length > 0 ? { external: fields.external } : {}),
  365. }
  366. }
  367. /**
  368. * Order composed rows so every requested dynamic package precedes its
  369. * consumers. An `external` specifier is either the package row it names
  370. * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
  371. * graph edge.
  372. * @param entries - composed rows in scan order.
  373. * @returns the same rows reordered; scan order breaks every tie.
  374. * @throws {Error} when a row requests itself or when the module graph has a
  375. * cycle; the message lists the packages on it.
  376. */
  377. export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[] {
  378. const rowsById = new Map<string, WebBootEntry>()
  379. for (const entry of entries) rowsById.set(entry.id, entry)
  380. const ordered: WebBootEntry[] = []
  381. const placed = new Set<string>()
  382. const open: string[] = []
  383. const visit = (entry: WebBootEntry): void => {
  384. if (placed.has(entry.id)) return
  385. const cycleStart = open.indexOf(entry.id)
  386. if (cycleStart !== -1) {
  387. throw new Error(
  388. `client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(' -> ')} `
  389. + '— a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports',
  390. )
  391. }
  392. open.push(entry.id)
  393. for (const name of entry.external ?? []) {
  394. const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name))
  395. if (dependency === entry) {
  396. throw new Error(
  397. `client-modules: "${entry.id}" requests module "${name}" that it answers itself `
  398. + '— a row must not declare its own package in dsh.client.external',
  399. )
  400. }
  401. if (dependency !== undefined) visit(dependency)
  402. }
  403. open.pop()
  404. placed.add(entry.id)
  405. ordered.push(entry)
  406. }
  407. for (const entry of entries) visit(entry)
  408. return ordered
  409. }
  410. /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
  411. const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
  412. /** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */
  413. const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
  414. /**
  415. * The boot protocol as index injection rows. The inline registration queue
  416. * precedes the application-batch preload and the blocking bootstrap batch. Its
  417. * `create()` method materializes the modules
  418. * bundle, delegates construction to that bundle, and leaves the same facade
  419. * in live-registration mode. The graph global follows before the shell reads
  420. * it.
  421. * @param graph - the composed entry graph.
  422. * @returns head rows in execution order: queue script, application preloads,
  423. * blocking bootstrap scripts, graph global.
  424. */
  425. export function bootInjections(graph: WebBootGraph): IndexInjection[] {
  426. const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
  427. const queue = `(()=>{
  428. const pendingQueue=[]
  429. window.__ModuleLoader__={
  430. mode:"queue",
  431. pendingQueue,
  432. load(registration){pendingQueue.push(registration)},
  433. create(options){
  434. if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
  435. const index=pendingQueue.findIndex(registration=>registration.id===${bootstrapId})
  436. const registration=pendingQueue[index]
  437. if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
  438. pendingQueue.splice(index,1)
  439. const exports=registration.factory(specifier=>{
  440. throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
  441. })
  442. if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
  443. throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
  444. }
  445. return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
  446. }
  447. }
  448. })()`
  449. const bootstrap = graph.batches.filter(batch => batch.phase === 'bootstrap')
  450. const application = graph.batches.filter(batch => batch.phase === 'application')
  451. const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
  452. for (const batch of application) {
  453. rows.push({ kind: 'script-preload', src: batch.url })
  454. }
  455. for (const batch of bootstrap) {
  456. rows.push({ kind: 'script-src', placement: 'head', src: batch.url })
  457. }
  458. rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
  459. return rows
  460. }
  461. /**
  462. * The web plugin table service: incremental `dsh.client` scan + wire composition
  463. * + bundle route + index injection rows. Construction runs the activation scan
  464. * synchronously — a malformed declaration or missing bundle among the
  465. * already-loaded entries aggregates into one loud throw (FAILED fiber; the
  466. * boot activation audit reports it).
  467. */
  468. export class ClientModuleRegistry extends Service {
  469. static inject = ['webServer', 'loader']
  470. private readonly table = new Map<string, WebPluginRecord>()
  471. // Negative verdicts (unresolvable specifier — builtins like cordis:include,
  472. // subpath rows — or a package without a web `dsh.client` declaration) are
  473. // cached as null and never expire: plugin-set changes take effect on restart.
  474. private readonly pkgMeta = new Map<string, PkgMeta | null>()
  475. private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
  476. private readonly graphListeners = new Set<() => void>()
  477. private readonly dirty = new Set<string>()
  478. private readonly resolvePkgJson: (spec: string) => string
  479. private readonly initialRevisionNonce = randomBytes(8).toString('hex')
  480. private nextInitialRevision = 0
  481. private responses = new Map<string, { body: Buffer; contentType: string }>()
  482. private batchResponses = new Map<string, { body: Buffer; contentType: string }>()
  483. /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
  484. private previousBatchResponses = new Map<string, { body: Buffer; contentType: string }>()
  485. private flushQueued = false
  486. private composed: WebBootGraph
  487. /**
  488. * Build the service: subscribe, seed, and run the activation flush.
  489. * @param ctx - plugin context carrying webServer and loader.
  490. */
  491. constructor(ctx: Context) {
  492. super(ctx, 'clientModules')
  493. // Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
  494. // whose package declares every composed plugin as a dependency). The
  495. // modules package's own URL would miss sibling packages under pnpm's
  496. // isolated node_modules.
  497. if (ctx.baseUrl === undefined) {
  498. throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
  499. }
  500. const require = createRequire(ctx.baseUrl)
  501. this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
  502. // Subscribe before seeding so a fiber arriving mid-activation lands in the
  503. // same dirty set (Set idempotence makes the overlap harmless). An entry-less
  504. // fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
  505. ctx.on('internal/plugin', (fiber) => {
  506. const entryName = fiber.entry?.options.name
  507. if (entryName === undefined) return
  508. this.dirty.add(entryName)
  509. if (this.flushQueued) return
  510. this.flushQueued = true
  511. queueMicrotask(() => {
  512. this.flushQueued = false
  513. this.flush((err) => { ctx.logger.warn(err) })
  514. })
  515. })
  516. // Activation pass: the initial scan IS the incremental path over the
  517. // current entries, flushed synchronously (nothing async between subscribe,
  518. // seed, and flush).
  519. for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
  520. this.composed = this.compose()
  521. const failures: Error[] = []
  522. this.flush(err => failures.push(err))
  523. if (failures.length > 0) {
  524. throw new ClientPackageCompositionError(failures)
  525. }
  526. ctx.effect(
  527. () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
  528. 'client-modules: bundle route',
  529. )
  530. ctx.on('webserver/index-inject', (table) => {
  531. table.push(...bootInjections(this.composed))
  532. })
  533. }
  534. /**
  535. * Current composed entry graph (stable object between changes).
  536. * @returns the graph served as `window.__DSH_BOOT__`.
  537. */
  538. graph(): WebBootGraph {
  539. return this.composed
  540. }
  541. /**
  542. * Absolute path of an entry's client bundle.
  543. * @param id - entry id (package name).
  544. * @returns the path, or undefined for an unknown id.
  545. */
  546. clientPath(id: string): string | undefined {
  547. return this.table.get(id)?.meta.clientPath
  548. }
  549. /**
  550. * Filesystem baseline captured before an entry's current bytes were read.
  551. * HMR compares it with the live files when installing a watch, so a write
  552. * between startup composition and watch installation cannot disappear into
  553. * the watcher's initial state.
  554. * @param id - entry id (package name).
  555. * @returns the path and baseline, or undefined for an unknown id.
  556. */
  557. artifactBaseline(id: string): ClientArtifactBaseline | undefined {
  558. const baseline = this.table.get(id)?.baseline
  559. return baseline === undefined ? undefined : { ...baseline }
  560. }
  561. /**
  562. * Re-hash one bundle (the HMR watch's registration hook — the only entry
  563. * point through which bundle content changes reach the graph).
  564. * @param id - entry id (package name).
  565. * @returns the new rev, or undefined for an unknown id.
  566. */
  567. rebuilt(id: string): string | undefined {
  568. const record = this.table.get(id)
  569. if (record === undefined) return undefined
  570. const baseline = this.captureArtifactBaseline(record.meta.clientPath)
  571. const bundle = readFileSync(record.meta.clientPath)
  572. const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath)
  573. const rev = artifactRevision(bundle, sourceMap)
  574. record.baseline = baseline
  575. if (rev === record.entry.rev) return rev
  576. record.entry = graphRow(id, rev, record.meta)
  577. record.bundle = bundle
  578. if (sourceMap === undefined) delete record.sourceMap
  579. else record.sourceMap = sourceMap
  580. this.composed = this.compose()
  581. for (const notify of this.rebuildListeners) {
  582. // Containment: rebuilt() runs inside the HMR watch callback — a
  583. // throwing subscriber must not kill the poll or skip later subscribers.
  584. try {
  585. notify(id, rev)
  586. } catch (error) {
  587. this.ctx.logger.error(error)
  588. }
  589. }
  590. this.notifyGraphChanged()
  591. return rev
  592. }
  593. /**
  594. * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
  595. * @param listener - receives the entry id and its new bundle rev.
  596. * @returns the unsubscriber.
  597. */
  598. onRebuilt(listener: (id: string, rev: string) => void): () => void {
  599. this.rebuildListeners.add(listener)
  600. return () => { this.rebuildListeners.delete(listener) }
  601. }
  602. /**
  603. * Fires after any flush that recomposed the graph (row added/removed, or a
  604. * rebuilt rev change). Pull model: listeners re-read {@link graph}.
  605. * @param listener - notified with no payload.
  606. * @returns the unsubscriber.
  607. */
  608. onGraphChanged(listener: () => void): () => void {
  609. this.graphListeners.add(listener)
  610. return () => { this.graphListeners.delete(listener) }
  611. }
  612. private compose(): WebBootGraph {
  613. const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
  614. const bootstrap = PARSER_PRELOAD_IDS
  615. .map(id => this.table.get(id))
  616. .filter((record): record is WebPluginRecord => record !== undefined)
  617. const bootstrapIds = new Set(bootstrap.map(record => record.entry.id))
  618. const application = entries
  619. .filter(entry => !bootstrapIds.has(entry.id))
  620. .map(entry => this.table.get(entry.id))
  621. .filter((record): record is WebPluginRecord => record !== undefined)
  622. const artifacts: BatchArtifact[] = []
  623. for (const records of partitionComboRecords(bootstrap)) {
  624. artifacts.push(buildBatch('bootstrap', records))
  625. }
  626. for (const records of partitionComboRecords(application)) {
  627. artifacts.push(buildBatch('application', records))
  628. }
  629. const batchResponses = new Map<string, { body: Buffer; contentType: string }>()
  630. for (const artifact of artifacts) {
  631. batchResponses.set(artifact.descriptor.url, {
  632. body: artifact.script,
  633. contentType: 'text/javascript; charset=utf-8',
  634. })
  635. batchResponses.set(artifact.sourceMapUrl, {
  636. body: artifact.sourceMap,
  637. contentType: 'application/json; charset=utf-8',
  638. })
  639. }
  640. const responses = new Map(batchResponses)
  641. for (const record of this.table.values()) {
  642. const artifact = buildCombo([record], record.entry.rev)
  643. responses.set(artifact.url, {
  644. body: artifact.script,
  645. contentType: 'text/javascript; charset=utf-8',
  646. })
  647. responses.set(artifact.sourceMapUrl, {
  648. body: artifact.sourceMap,
  649. contentType: 'application/json; charset=utf-8',
  650. })
  651. }
  652. this.previousBatchResponses = this.batchResponses
  653. this.batchResponses = batchResponses
  654. this.responses = responses
  655. const batches = artifacts.map(artifact => artifact.descriptor)
  656. return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
  657. }
  658. private notifyGraphChanged(): void {
  659. for (const listener of this.graphListeners) {
  660. // A throwing subscriber must not skip later subscribers (or escape into
  661. // whatever triggered the flush — possibly an fs.watchFile callback).
  662. try {
  663. listener()
  664. } catch (error) {
  665. this.ctx.logger.error(error)
  666. }
  667. }
  668. }
  669. private resolveMeta(pkgName: string): PkgMeta | null {
  670. const cached = this.pkgMeta.get(pkgName)
  671. if (cached !== undefined) return cached
  672. let pkgPath: string
  673. try {
  674. pkgPath = this.resolvePkgJson(pkgName)
  675. } catch {
  676. // Not a resolvable package root: loader builtins (cordis:include) and
  677. // subpath entries (…/gateway) land here — permanently not a client row.
  678. this.pkgMeta.set(pkgName, null)
  679. return null
  680. }
  681. const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
  682. const dsh = pkg.dsh
  683. const decl = parseDshClient(
  684. pkgName,
  685. dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
  686. )
  687. if (decl === undefined || decl.platform !== 'web') {
  688. this.pkgMeta.set(pkgName, null)
  689. return null
  690. }
  691. const clientRel = clientExportOf(pkgName, pkg.exports)
  692. if (clientRel === undefined) {
  693. throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
  694. }
  695. const meta: PkgMeta = {
  696. clientPath: join(dirname(pkgPath), clientRel),
  697. ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
  698. external: decl.external ?? [],
  699. immediately: decl.immediately === true,
  700. }
  701. this.pkgMeta.set(pkgName, meta)
  702. return meta
  703. }
  704. /** Capture the bundle and optional-map stats before reading their bytes. */
  705. private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
  706. const bundle = statSync(clientPath)
  707. let sourceMap: Stats | undefined
  708. try {
  709. sourceMap = statSync(`${clientPath}.map`)
  710. } catch {
  711. // Optional map metadata only seeds HMR; the following map read reports
  712. // malformed or inaccessible bytes and a later stat change self-heals.
  713. }
  714. return {
  715. path: clientPath,
  716. mtimeMs: bundle.mtimeMs,
  717. size: bundle.size,
  718. mapMtimeMs: sourceMap?.mtimeMs ?? null,
  719. mapSize: sourceMap?.size ?? null,
  720. }
  721. }
  722. /** Allocate an opaque initial row revision without inspecting artifact bytes. */
  723. private allocateInitialRevision(): string {
  724. return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
  725. }
  726. /**
  727. * Read the activation-time bundle and optional source-map snapshots.
  728. * @param pkgName - package that declares the client bundle.
  729. * @param clientPath - absolute path of the built client artifact.
  730. * @returns the immutable bytes plus the pre-read filesystem baseline.
  731. * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
  732. */
  733. private initialBundleSnapshot(pkgName: string, clientPath: string): {
  734. bundle: Buffer
  735. baseline: ClientArtifactBaseline
  736. sourceMap?: WebPluginRecord['sourceMap']
  737. } {
  738. try {
  739. const baseline = this.captureArtifactBaseline(clientPath)
  740. const bundle = readFileSync(clientPath)
  741. const sourceMap = this.readSourceMapSnapshot(clientPath)
  742. return { bundle, baseline, ...(sourceMap === undefined ? {} : { sourceMap }) }
  743. } catch (error) {
  744. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  745. throw new MissingClientBundleError(pkgName, clientPath, error)
  746. }
  747. }
  748. /** Treat a missing, torn, or malformed development map as an identity-mapped artifact revision. */
  749. private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
  750. try {
  751. return sourceMapSnapshot(clientPath)
  752. } catch (error) {
  753. this.ctx.logger.warn(error)
  754. return undefined
  755. }
  756. }
  757. /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
  758. private processOne(entryName: string): boolean {
  759. let qualifies = false
  760. for (const entry of this.ctx.loader.entries()) {
  761. if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
  762. qualifies = true
  763. break
  764. }
  765. }
  766. if (!qualifies) return this.table.delete(entryName)
  767. if (this.table.has(entryName)) return false
  768. const meta = this.resolveMeta(entryName)
  769. if (meta === null) return false
  770. // The opaque initial rev rides the row until HMR observes a file change;
  771. // a fiber restart reuses the existing row without inspecting bytes.
  772. const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath)
  773. const rev = this.allocateInitialRevision()
  774. this.table.set(entryName, {
  775. entry: graphRow(entryName, rev, meta),
  776. meta,
  777. bundle: snapshot.bundle,
  778. baseline: snapshot.baseline,
  779. ...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
  780. })
  781. return true
  782. }
  783. private flush(onError: (err: Error) => void): void {
  784. let changed = false
  785. for (const entryName of [...this.dirty]) {
  786. this.dirty.delete(entryName)
  787. try {
  788. if (this.processOne(entryName)) changed = true
  789. } catch (error) {
  790. // Steady state: one broken package must not poison the others; the
  791. // activation pass aggregates these into a loud throw instead.
  792. onError(error instanceof Error ? error : new Error(String(error)))
  793. }
  794. }
  795. if (!changed) return
  796. let composed: WebBootGraph
  797. try {
  798. composed = this.compose()
  799. } catch (error) {
  800. // An unorderable module graph is a property of the whole table, not of
  801. // the arriving package, so it surfaces here: aggregated into the
  802. // activation throw, or warned in steady state while the last orderable
  803. // graph stays served.
  804. onError(error as Error)
  805. return
  806. }
  807. this.composed = composed
  808. this.notifyGraphChanged()
  809. }
  810. private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => {
  811. if (req.method !== 'GET' && req.method !== 'HEAD') {
  812. res.writeHead(405)
  813. res.end()
  814. return
  815. }
  816. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
  817. const requestUrl = new URL(req.url ?? '/', 'http://x')
  818. const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
  819. const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl)
  820. if (response !== undefined) {
  821. res.writeHead(200, {
  822. 'content-type': response.contentType,
  823. 'cache-control': IMMUTABLE_CACHE,
  824. })
  825. res.end(req.method === 'HEAD' ? undefined : response.body)
  826. return
  827. }
  828. // Anything else under /plugins (including unadvertised combinations and
  829. // /plugins/events when the HMR row is absent) is an unknown resource.
  830. res.writeHead(404)
  831. res.end()
  832. }
  833. }
  834. export default ClientModuleRegistry