index.ts 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058
  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. * `clientModules` 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 Loader specifier and
  19. * owning-tree base URL until restart. The manifest package name identifies
  20. * the browser module; distinct active Loader sources for that package are a
  21. * composition error. Bundle content changes reach the graph only through
  22. * {@link ClientModuleRegistry.rebuilt}.
  23. * @module @deepseek-ai/dsh-client-modules
  24. */
  25. import { createHash, randomBytes } from 'node:crypto'
  26. import { existsSync, readFileSync, statSync } from 'node:fs'
  27. import type { IncomingMessage, ServerResponse } from 'node:http'
  28. import { createRequire } from 'node:module'
  29. import { dirname, isAbsolute, join } from 'node:path'
  30. import { fileURLToPath, pathToFileURL } from 'node:url'
  31. import { Service } from '@deepseek-ai/cordis'
  32. import type { Context } from '@deepseek-ai/cordis'
  33. import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
  34. import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
  35. import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
  36. import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
  37. export { stripClientSuffix } from './client/manifest.ts'
  38. export type {
  39. BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph,
  40. } from './client/manifest.ts'
  41. declare module '@deepseek-ai/cordis' {
  42. interface Context {
  43. /** The web plugin table (provided by the client-modules node half). */
  44. clientModules: ClientModuleRegistry
  45. }
  46. }
  47. /** The declared fields a graph row carries, normalized (absent array declarations become empty). */
  48. interface WebBootRowFields {
  49. inject?: string[]
  50. /** Module specifiers the package requests from the module table. */
  51. external: string[]
  52. immediately: boolean
  53. }
  54. /** Filesystem baseline captured before a client artifact snapshot is read. */
  55. export interface ClientArtifactBaseline {
  56. /** Absolute path of the client bundle. */
  57. readonly path: string
  58. /** Bundle modification time in milliseconds. */
  59. readonly mtimeMs: number
  60. /** Bundle size in bytes. */
  61. readonly size: number
  62. }
  63. /** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
  64. interface PkgMeta extends WebBootRowFields {
  65. clientPath: string
  66. }
  67. interface ResolvedPkgMeta {
  68. packageName: string
  69. meta: PkgMeta
  70. }
  71. /** One active Loader source and the browser package manifest it resolves to. */
  72. interface ClientPackageSource extends ResolvedPkgMeta {
  73. /** Loader specifier from the active row. */
  74. loaderName: string
  75. /** Resolution base of the config tree that owns the row. */
  76. baseUrl: string
  77. /** Stable cache and contribution key for this source. */
  78. sourceKey: string
  79. }
  80. /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
  81. const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
  82. /** Missing built client export, retained as structured data for activation-error grouping. */
  83. class MissingClientBundleError extends Error {
  84. constructor(
  85. readonly packageName: string,
  86. readonly clientPath: string,
  87. cause: unknown,
  88. ) {
  89. super(
  90. [
  91. `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
  92. ` package: ${packageName}`,
  93. ` path: ${clientPath}`,
  94. ].join('\n'),
  95. { cause },
  96. )
  97. }
  98. }
  99. /** Activation failures grouped by actionable package-build errors and unrelated failures. */
  100. class ClientPackageCompositionError extends AggregateError {
  101. constructor(failures: Error[]) {
  102. const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
  103. const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
  104. const packageNoun = failures.length === 1 ? 'package' : 'packages'
  105. const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
  106. if (missingBundles.length > 0) {
  107. lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
  108. for (const error of missingBundles) {
  109. lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
  110. }
  111. }
  112. if (otherFailures.length > 0) {
  113. lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
  114. }
  115. super(failures, lines.join('\n'))
  116. }
  117. }
  118. /** One composed table row: the wire entry plus the resolved package metadata behind it. */
  119. interface WebPluginRecord {
  120. entry: WebBootEntry
  121. /** Loader specifier whose active row contributes this browser module. */
  122. loaderName: string
  123. /** Loader resolution input that selected this package instance. */
  124. sourceKey: string
  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. }
  131. /** Immutable inputs captured for one resource in a generated combo. */
  132. interface ComboResource {
  133. id: string
  134. rev: string
  135. clientPath: string
  136. bundle: Buffer
  137. }
  138. /** One lazily materialized immutable response body. */
  139. interface LazyResponse {
  140. contentType: string
  141. body: () => Promise<Buffer>
  142. }
  143. /** Fields shared by every generated combo plan. */
  144. interface ComboArtifact {
  145. url: string
  146. rev: string
  147. entries: string[]
  148. sourceMapUrl: string
  149. scriptBody: () => Promise<Buffer>
  150. sourceMapBody: () => Promise<Buffer>
  151. }
  152. /** One generated initial-load response and its wire descriptor. */
  153. type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
  154. /** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
  155. const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
  156. /** Generated request URLs stay below conservative browser and intermediary request-target limits. */
  157. const MAX_COMBO_URL_BYTES = 3 * 1024
  158. const HASH_REVISION_LENGTH = 12
  159. const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
  160. /** Source-map trailer emitted by tsdown at the end of every client bundle. */
  161. const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
  162. /** Debugger source name appended to page bundles in the WebWorker image. */
  163. const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
  164. /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
  165. function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
  166. if (typeof exportsField !== 'object' || exportsField === null) return undefined
  167. const client = (exportsField as Record<string, unknown>)['./client']
  168. if (client === undefined) return undefined
  169. if (typeof client === 'string') return client
  170. if (typeof client === 'object' && client !== null) {
  171. const fallback = (client as Record<string, unknown>).default
  172. if (typeof fallback === 'string') return fallback
  173. }
  174. throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
  175. }
  176. /** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
  177. function shortHash(input: string | Buffer): string {
  178. return createHash('sha1').update(input).digest('hex').slice(0, HASH_REVISION_LENGTH)
  179. }
  180. /** Hash several response fields without allowing bytes to move across field boundaries. */
  181. function framedHash(domain: string, parts: readonly Buffer[]): string {
  182. const hash = createHash('sha1').update(domain).update('\0')
  183. for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part)
  184. return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
  185. }
  186. /** Hash the executable artifact served after HMR observes one plugin change. */
  187. function artifactRevision(bundle: Buffer): string {
  188. return framedHash('plugin-artifact', [bundle])
  189. }
  190. /** Address one ordered plugin-file list through the shared combo route. */
  191. function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
  192. const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
  193. return `/plugins/??${resources}&rev=${rev}`
  194. }
  195. /** Measure the longer map-form URL used to partition a startup resource list. */
  196. function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
  197. return Buffer.byteLength(comboUrl(
  198. records.map(record => record.entry.id),
  199. COMBO_REVISION_PLACEHOLDER,
  200. true,
  201. ))
  202. }
  203. /** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
  204. function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRecord[][] {
  205. const chunks: WebPluginRecord[][] = []
  206. let current: WebPluginRecord[] = []
  207. for (const record of records) {
  208. const candidate = [...current, record]
  209. if (projectedComboUrlBytes(candidate) <= MAX_COMBO_URL_BYTES) {
  210. current = candidate
  211. continue
  212. }
  213. if (current.length === 0) {
  214. throw new Error(
  215. `client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
  216. )
  217. }
  218. chunks.push(current)
  219. current = [record]
  220. if (projectedComboUrlBytes(current) > MAX_COMBO_URL_BYTES) {
  221. throw new Error(
  222. `client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
  223. )
  224. }
  225. }
  226. if (current.length > 0) chunks.push(current)
  227. return chunks
  228. }
  229. /** Executable source plus the generated-file name used when no authored map exists. */
  230. interface PreparedSource {
  231. source: string
  232. fallbackSource: string
  233. }
  234. /** Remove bundle-local debug directives and retain their stable generated-file name. */
  235. function prepareSource(resource: ComboResource): PreparedSource {
  236. let source = resource.bundle.toString('utf8')
  237. const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1]
  238. source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
  239. if (!source.endsWith('\n')) source += '\n'
  240. const fallbackSource = sourceUrl === undefined
  241. ? `/plugins/${resource.id}/client.js`
  242. : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
  243. return { source, fallbackSource }
  244. }
  245. /** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
  246. function comboScript(input: string, sourceMapUrl?: string): Buffer {
  247. return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
  248. }
  249. /** Read and parse an optional source map when its combo-map endpoint is requested. */
  250. function readSourceMap(clientPath: string): Record<string, unknown> | undefined {
  251. let body: Buffer
  252. try {
  253. body = readFileSync(`${clientPath}.map`)
  254. } catch (error) {
  255. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
  256. throw error
  257. }
  258. const value = JSON.parse(body.toString('utf8')) as unknown
  259. const parsed = typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
  260. if (
  261. parsed === undefined
  262. || parsed.version !== 3
  263. || !Array.isArray(parsed.sources)
  264. || parsed.sources.some(source => typeof source !== 'string')
  265. || !Array.isArray(parsed.names)
  266. || parsed.names.some(name => typeof name !== 'string')
  267. || typeof parsed.mappings !== 'string'
  268. ) {
  269. throw new Error(`client-modules: ${clientPath}.map is not a regular Source Map v3 object`)
  270. }
  271. return parsed
  272. }
  273. /** Count generated lines while assembling indexed-map section offsets. */
  274. function newlineCount(value: string): number {
  275. let count = 0
  276. for (const char of value) if (char === '\n') count += 1
  277. return count
  278. }
  279. /** Resolve section sources against their original per-plugin map URL before combo relocation. */
  280. function comboSectionMap(resource: ComboResource, original: Record<string, unknown>): Record<string, unknown> {
  281. const sourcePaths = original.sources as string[]
  282. const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
  283. const base = new URL(`/plugins/${resource.id}/client.js.map`, 'http://dsh.invalid')
  284. const relocated = sourcePaths.map((source) => {
  285. const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
  286. const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
  287. return resolved.origin === base.origin
  288. ? `${resolved.pathname}${resolved.search}${resolved.hash}`
  289. : resolved.href
  290. })
  291. const section: Record<string, unknown> = { ...original, sources: relocated }
  292. delete section.sourceRoot
  293. return section
  294. }
  295. /** Map each generated line to the same line in a bundled JavaScript source. */
  296. function identitySectionMap(source: string, sourceUrl: string): Record<string, unknown> {
  297. const mappings = Array.from({ length: newlineCount(source) }, (_, index) => index === 0 ? 'AAAA' : 'AACA')
  298. .join(';')
  299. return {
  300. version: 3,
  301. names: [],
  302. sources: [sourceUrl],
  303. sourcesContent: [source],
  304. mappings,
  305. }
  306. }
  307. /** Run one producer in the first requester's microtask, not off-thread, and share its settlement. */
  308. function lazyBody(produce: () => Buffer): () => Promise<Buffer> {
  309. let result: Promise<Buffer> | undefined
  310. return () => {
  311. result ??= Promise.resolve().then(produce)
  312. return result
  313. }
  314. }
  315. /** Derive one combo revision from the ordered immutable row revisions. */
  316. function comboRevision(resources: readonly ComboResource[]): string {
  317. return framedHash('combo', resources.flatMap(resource => [
  318. Buffer.from(resource.id),
  319. Buffer.from(resource.rev),
  320. ]))
  321. }
  322. /** Concatenate one or more factory registrations without reading or composing source maps. */
  323. function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: string): Buffer {
  324. let source = ''
  325. for (const resource of resources) source += `${prepareSource(resource).source};\n`
  326. return comboScript(source, sourceMapUrl)
  327. }
  328. /** Compose one indexed map from source-map files read only for this request. */
  329. function buildComboSourceMap(
  330. resources: readonly ComboResource[],
  331. sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
  332. ): Buffer {
  333. const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
  334. let line = 0
  335. for (const resource of resources) {
  336. const prepared = prepareSource(resource)
  337. const sourceMap = sourceMapOf(resource.clientPath)
  338. let section = identitySectionMap(prepared.source, prepared.fallbackSource)
  339. if (sourceMap !== undefined) {
  340. try {
  341. section = comboSectionMap(resource, sourceMap)
  342. } catch {
  343. // An invalid authored source URL is a malformed map, so the generated
  344. // bundle remains debuggable through the same identity fallback.
  345. }
  346. }
  347. sections.push({ offset: { line, column: 0 }, map: section })
  348. line += newlineCount(`${prepared.source};\n`)
  349. }
  350. return Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
  351. }
  352. /** Describe one combo and defer its executable and debug payloads independently. */
  353. function buildCombo(
  354. records: readonly WebPluginRecord[],
  355. sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
  356. revision?: string,
  357. ): ComboArtifact {
  358. const resources = records.map(record => ({
  359. id: record.entry.id,
  360. rev: record.entry.rev,
  361. clientPath: record.meta.clientPath,
  362. bundle: record.bundle,
  363. }))
  364. const rev = revision ?? comboRevision(resources)
  365. const entries = resources.map(resource => resource.id)
  366. const url = comboUrl(entries, rev)
  367. const sourceMapUrl = comboUrl(entries, rev, true)
  368. return {
  369. url,
  370. rev,
  371. entries,
  372. sourceMapUrl,
  373. scriptBody: lazyBody(() => buildComboScript(resources, sourceMapUrl)),
  374. sourceMapBody: lazyBody(() => buildComboSourceMap(resources, sourceMapOf)),
  375. }
  376. }
  377. /** Add initial-load scheduling metadata to a combo artifact. */
  378. function buildBatch(
  379. phase: WebBootBatchPhase,
  380. records: readonly WebPluginRecord[],
  381. sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
  382. ): BatchArtifact {
  383. const artifact = buildCombo(records, sourceMapOf)
  384. return {
  385. ...artifact,
  386. descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
  387. }
  388. }
  389. /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
  390. function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
  391. return {
  392. id,
  393. url: comboUrl([id], rev),
  394. rev,
  395. ...(fields.inject !== undefined ? { inject: fields.inject } : {}),
  396. ...(fields.immediately ? { immediately: true } : {}),
  397. ...(fields.external.length > 0 ? { external: fields.external } : {}),
  398. }
  399. }
  400. /**
  401. * Order composed rows so every requested dynamic package precedes its
  402. * consumers. An `external` specifier is either the package row it names
  403. * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
  404. * graph edge.
  405. * @param entries - composed rows in scan order.
  406. * @returns the same rows reordered; scan order breaks every tie.
  407. * @throws {Error} when a row requests itself or when the module graph has a
  408. * cycle; the message lists the packages on it.
  409. */
  410. export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[] {
  411. const rowsById = new Map<string, WebBootEntry>()
  412. for (const entry of entries) rowsById.set(entry.id, entry)
  413. const ordered: WebBootEntry[] = []
  414. const placed = new Set<string>()
  415. const open: string[] = []
  416. const visit = (entry: WebBootEntry): void => {
  417. if (placed.has(entry.id)) return
  418. const cycleStart = open.indexOf(entry.id)
  419. if (cycleStart !== -1) {
  420. throw new Error(
  421. `client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(' -> ')} `
  422. + '— a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports',
  423. )
  424. }
  425. open.push(entry.id)
  426. for (const name of entry.external ?? []) {
  427. const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name))
  428. if (dependency === entry) {
  429. throw new Error(
  430. `client-modules: "${entry.id}" requests module "${name}" that it answers itself `
  431. + '— a row must not declare its own package in dsh.client.external',
  432. )
  433. }
  434. if (dependency !== undefined) visit(dependency)
  435. }
  436. open.pop()
  437. placed.add(entry.id)
  438. ordered.push(entry)
  439. }
  440. for (const entry of entries) visit(entry)
  441. return ordered
  442. }
  443. /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
  444. const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
  445. /** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */
  446. const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
  447. /**
  448. * The boot protocol as index injection rows. The inline registration queue
  449. * precedes the application-batch preload and the blocking bootstrap batch. Its
  450. * `create()` method materializes the modules
  451. * bundle, delegates construction to that bundle, and leaves the same facade
  452. * in live-registration mode. The graph global follows before the shell reads
  453. * it.
  454. * @param graph - the composed entry graph.
  455. * @returns head rows in execution order: queue script, application preloads,
  456. * blocking bootstrap scripts, graph global.
  457. */
  458. export function bootInjections(graph: WebBootGraph): IndexInjection[] {
  459. const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
  460. const queue = `(()=>{
  461. const pendingQueue=[]
  462. window.__ModuleLoader__={
  463. mode:"queue",
  464. pendingQueue,
  465. load(registration){pendingQueue.push(registration)},
  466. create(options){
  467. if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
  468. const index=pendingQueue.findIndex(registration=>registration.id===${bootstrapId})
  469. const registration=pendingQueue[index]
  470. if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
  471. pendingQueue.splice(index,1)
  472. const exports=registration.factory(specifier=>{
  473. throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
  474. })
  475. if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
  476. throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
  477. }
  478. return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
  479. }
  480. }
  481. })()`
  482. const bootstrap = graph.batches.filter(batch => batch.phase === 'bootstrap')
  483. const application = graph.batches.filter(batch => batch.phase === 'application')
  484. const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
  485. for (const batch of application) {
  486. rows.push({ kind: 'script-preload', src: batch.url })
  487. }
  488. for (const batch of bootstrap) {
  489. rows.push({ kind: 'script-src', placement: 'head', src: batch.url })
  490. }
  491. rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
  492. return rows
  493. }
  494. /**
  495. * The web plugin table service: incremental `dsh.client` scan + wire composition
  496. * + bundle route + index injection rows. Construction runs the activation scan
  497. * synchronously — a malformed declaration or missing bundle among the
  498. * already-loaded entries aggregates into one loud throw (FAILED fiber; the
  499. * boot activation audit reports it).
  500. */
  501. export class ClientModuleRegistry extends Service {
  502. static inject = ['loader']
  503. private readonly table = new Map<string, WebPluginRecord>()
  504. private readonly sources = new Map<string, ClientPackageSource>()
  505. // Resolution is entry-local: the same specifier can resolve differently in
  506. // separate config trees. Negative verdicts remain stable until restart.
  507. private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
  508. private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
  509. private readonly graphListeners = new Set<() => void>()
  510. private readonly dirty = new Set<string>()
  511. private readonly initialRevisionNonce = randomBytes(8).toString('hex')
  512. private nextInitialRevision = 0
  513. private responses = new Map<string, LazyResponse>()
  514. private batchResponses = new Map<string, LazyResponse>()
  515. /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
  516. private previousBatchResponses = new Map<string, LazyResponse>()
  517. private flushQueued = false
  518. private composed: WebBootGraph
  519. /**
  520. * Build the service: subscribe, seed, and run the activation flush.
  521. * Bundle routes follow the optional Web carrier's injected lifecycle.
  522. * @param ctx - plugin context carrying Loader and an optional Web carrier.
  523. */
  524. constructor(ctx: Context) {
  525. super(ctx, 'clientModules')
  526. // Subscribe before seeding so a fiber arriving mid-activation lands in the
  527. // same dirty set (Set idempotence makes the overlap harmless). An entry-less
  528. // fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
  529. ctx.on('internal/plugin', (fiber) => {
  530. const entryName = fiber.entry?.options.name
  531. if (entryName === undefined) return
  532. this.dirty.add(entryName)
  533. if (this.flushQueued) return
  534. this.flushQueued = true
  535. queueMicrotask(() => {
  536. this.flushQueued = false
  537. this.flush((err) => { ctx.logger.warn(err) })
  538. })
  539. })
  540. // Activation pass: the initial scan IS the incremental path over the
  541. // current entries, flushed synchronously (nothing async between subscribe,
  542. // seed, and flush).
  543. for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
  544. this.composed = this.compose()
  545. const failures: Error[] = []
  546. this.flush(err => failures.push(err))
  547. if (failures.length > 0) {
  548. throw new ClientPackageCompositionError(failures)
  549. }
  550. const registerWebCarrier = (webCtx: Context): void => {
  551. webCtx.effect(
  552. () => webCtx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
  553. 'client-modules: bundle route',
  554. )
  555. }
  556. ctx.inject(['webServer'], registerWebCarrier)
  557. ctx.on('webserver/index-inject', (table) => {
  558. table.push(...bootInjections(this.composed))
  559. })
  560. }
  561. /**
  562. * Current composed entry graph (stable object between changes).
  563. * @returns the graph served as `window.__DSH_BOOT__`.
  564. */
  565. graph(): WebBootGraph {
  566. return this.composed
  567. }
  568. /**
  569. * Absolute path of an entry's client bundle.
  570. * @param id - entry id (package name).
  571. * @returns the path, or undefined for an unknown id.
  572. */
  573. clientPath(id: string): string | undefined {
  574. return this.table.get(id)?.meta.clientPath
  575. }
  576. /**
  577. * Serve an advertised revisioned bundle or source map without a Web server.
  578. * Unknown URLs return 404, unsupported methods return 405, and `HEAD`
  579. * returns the same immutable headers without materializing a body. Each body
  580. * is built once on its first `GET`; script construction never reads maps.
  581. * @param request - shell-carrier request for a `/plugins` resource.
  582. * @returns the exact response also exposed by the optional Web route.
  583. */
  584. async fetchBundle(request: Request): Promise<Response> {
  585. const resource = await this.bundleResource(request.method, request.url)
  586. const body = resource.body === undefined ? null : Uint8Array.from(resource.body)
  587. return new Response(body, {
  588. status: resource.status,
  589. ...(resource.headers === undefined ? {} : { headers: resource.headers }),
  590. })
  591. }
  592. /**
  593. * Filesystem baseline captured before an entry's current bytes were read.
  594. * HMR compares it with the live files when installing a watch, so a write
  595. * between startup composition and watch installation cannot disappear into
  596. * the watcher's initial state.
  597. * @param id - entry id (package name).
  598. * @returns the path and baseline, or undefined for an unknown id.
  599. */
  600. artifactBaseline(id: string): ClientArtifactBaseline | undefined {
  601. const baseline = this.table.get(id)?.baseline
  602. return baseline === undefined ? undefined : { ...baseline }
  603. }
  604. /**
  605. * Re-hash one bundle (the HMR watch's registration hook — the only entry
  606. * point through which bundle content changes reach the graph).
  607. * @param id - entry id (package name).
  608. * @returns the new rev, or undefined for an unknown id.
  609. */
  610. rebuilt(id: string): string | undefined {
  611. const record = this.table.get(id)
  612. if (record === undefined) return undefined
  613. const baseline = this.captureArtifactBaseline(record.meta.clientPath)
  614. const bundle = readFileSync(record.meta.clientPath)
  615. const rev = artifactRevision(bundle)
  616. record.baseline = baseline
  617. if (rev === record.entry.rev) return rev
  618. record.entry = graphRow(id, rev, record.meta)
  619. record.bundle = bundle
  620. this.composed = this.compose()
  621. for (const notify of this.rebuildListeners) {
  622. // Containment: rebuilt() runs inside the HMR watch callback — a
  623. // throwing subscriber must not kill the poll or skip later subscribers.
  624. try {
  625. notify(id, rev)
  626. } catch (error) {
  627. this.ctx.logger.error(error)
  628. }
  629. }
  630. this.notifyGraphChanged()
  631. return rev
  632. }
  633. /**
  634. * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
  635. * @param listener - receives the entry id and its new bundle rev.
  636. * @returns the unsubscriber.
  637. */
  638. onRebuilt(listener: (id: string, rev: string) => void): () => void {
  639. this.rebuildListeners.add(listener)
  640. return () => { this.rebuildListeners.delete(listener) }
  641. }
  642. /**
  643. * Fires after any flush that recomposed the graph (row added/removed, or a
  644. * rebuilt rev change). Pull model: listeners re-read {@link graph}.
  645. * @param listener - notified with no payload.
  646. * @returns the unsubscriber.
  647. */
  648. onGraphChanged(listener: () => void): () => void {
  649. this.graphListeners.add(listener)
  650. return () => { this.graphListeners.delete(listener) }
  651. }
  652. private compose(): WebBootGraph {
  653. const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
  654. const bootstrap = PARSER_PRELOAD_IDS
  655. .map(id => this.table.get(id))
  656. .filter((record): record is WebPluginRecord => record !== undefined)
  657. const bootstrapIds = new Set(bootstrap.map(record => record.entry.id))
  658. const application = entries
  659. .filter(entry => !bootstrapIds.has(entry.id))
  660. .map(entry => this.table.get(entry.id))
  661. .filter((record): record is WebPluginRecord => record !== undefined)
  662. const artifacts: BatchArtifact[] = []
  663. for (const records of partitionComboRecords(bootstrap)) {
  664. artifacts.push(buildBatch('bootstrap', records, this.readSourceMap))
  665. }
  666. for (const records of partitionComboRecords(application)) {
  667. artifacts.push(buildBatch('application', records, this.readSourceMap))
  668. }
  669. const batchResponses = new Map<string, LazyResponse>()
  670. for (const artifact of artifacts) {
  671. batchResponses.set(artifact.descriptor.url, this.responses.get(artifact.descriptor.url) ?? {
  672. body: artifact.scriptBody,
  673. contentType: 'text/javascript; charset=utf-8',
  674. })
  675. batchResponses.set(artifact.sourceMapUrl, this.responses.get(artifact.sourceMapUrl) ?? {
  676. body: artifact.sourceMapBody,
  677. contentType: 'application/json; charset=utf-8',
  678. })
  679. }
  680. const responses = new Map(batchResponses)
  681. for (const record of this.table.values()) {
  682. const artifact = buildCombo([record], this.readSourceMap, record.entry.rev)
  683. responses.set(artifact.url, responses.get(artifact.url) ?? this.responses.get(artifact.url) ?? {
  684. body: artifact.scriptBody,
  685. contentType: 'text/javascript; charset=utf-8',
  686. })
  687. responses.set(artifact.sourceMapUrl, responses.get(artifact.sourceMapUrl) ?? this.responses.get(artifact.sourceMapUrl) ?? {
  688. body: artifact.sourceMapBody,
  689. contentType: 'application/json; charset=utf-8',
  690. })
  691. }
  692. this.previousBatchResponses = this.batchResponses
  693. this.batchResponses = batchResponses
  694. this.responses = responses
  695. const batches = artifacts.map(artifact => artifact.descriptor)
  696. return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
  697. }
  698. private notifyGraphChanged(): void {
  699. for (const listener of this.graphListeners) {
  700. // A throwing subscriber must not skip later subscribers (or escape into
  701. // whatever triggered the flush — possibly an fs.watchFile callback).
  702. try {
  703. listener()
  704. } catch (error) {
  705. this.ctx.logger.error(error)
  706. }
  707. }
  708. }
  709. private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
  710. const sourceKey = this.sourceKey(loaderName, baseUrl)
  711. const cached = this.pkgMeta.get(sourceKey)
  712. if (cached !== undefined) return cached
  713. const located = this.locatePkgJson(loaderName, baseUrl)
  714. if (located === undefined) {
  715. // Not a resolvable package root: loader builtins (cordis:include) and
  716. // subpath entries (…/gateway) land here — permanently not a client row.
  717. this.pkgMeta.set(sourceKey, null)
  718. return null
  719. }
  720. const { packageName, path: pkgPath } = located
  721. const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
  722. const dsh = pkg.dsh
  723. const decl = parseDshClient(
  724. packageName,
  725. dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
  726. )
  727. if (decl === undefined || decl.platform !== 'web') {
  728. this.pkgMeta.set(sourceKey, null)
  729. return null
  730. }
  731. const clientRel = clientExportOf(packageName, pkg.exports)
  732. if (clientRel === undefined) {
  733. throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
  734. }
  735. const meta: PkgMeta = {
  736. clientPath: join(dirname(pkgPath), clientRel),
  737. ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
  738. external: decl.external ?? [],
  739. immediately: decl.immediately === true,
  740. }
  741. const resolved = { packageName, meta }
  742. this.pkgMeta.set(sourceKey, resolved)
  743. return resolved
  744. }
  745. /**
  746. * Locate the manifest of the package the Loader mounts for a row. The row's
  747. * module location is authoritative: the specifier resolves through the same
  748. * Loader resolution that imported the row's host half — including any
  749. * active ESM hooks — and the nearest ancestor manifest declaring the name
  750. * owns the module. Tree-anchored `require` resolution remains only for
  751. * runtimes without Node internals.
  752. * @param loaderName - module specifier of the loader row.
  753. * @param baseUrl - resolution base of the tree that owns the row.
  754. * @returns the manifest path, or `undefined` when the name resolves to no package root.
  755. */
  756. private locatePkgJson(loaderName: string, baseUrl: string): { path: string; packageName: string } | undefined {
  757. if (loaderName.startsWith('cordis:')) return undefined
  758. const pathLike = loaderName.startsWith('.') || loaderName.startsWith('file:') || isAbsolute(loaderName)
  759. const expectedPackageName = pathLike ? undefined : exactPackageSpecifier(loaderName)
  760. if (!pathLike && expectedPackageName === undefined) return undefined
  761. const internal = this.ctx.loader.internal
  762. if (internal === undefined || typeof Reflect.get(internal, 'resolveSync') !== 'function') {
  763. if (expectedPackageName === undefined) {
  764. const moduleUrl = loaderName.startsWith('file:')
  765. ? loaderName
  766. : isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href
  767. return this.nearestPackage(moduleUrl)
  768. }
  769. try {
  770. return {
  771. path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`),
  772. packageName: expectedPackageName,
  773. }
  774. } catch {
  775. // Without Node internals the owning tree is the only resolver; an
  776. // unresolvable name is classified exactly as below.
  777. return undefined
  778. }
  779. }
  780. let moduleUrl: string
  781. try {
  782. moduleUrl = internal.version === 'v2'
  783. ? internal.resolveSync(baseUrl, { specifier: loaderName, attributes: {} }).url
  784. : internal.resolveSync(loaderName, baseUrl, {}).url
  785. } catch {
  786. // The Loader cannot resolve the name: its row cannot have imported, so
  787. // the name is permanently not a client row.
  788. return undefined
  789. }
  790. return this.nearestPackage(moduleUrl, expectedPackageName)
  791. }
  792. private nearestPackage(
  793. moduleUrl: string,
  794. expectedPackageName?: string,
  795. ): { path: string; packageName: string } | undefined {
  796. if (!moduleUrl.startsWith('file:')) return undefined
  797. let dir = dirname(fileURLToPath(moduleUrl))
  798. while (true) {
  799. const candidate = join(dir, 'package.json')
  800. if (existsSync(candidate)) {
  801. try {
  802. const name = (JSON.parse(readFileSync(candidate, 'utf8')) as { name?: unknown }).name
  803. if (typeof name === 'string' && (expectedPackageName === undefined || name === expectedPackageName)) {
  804. return { path: candidate, packageName: name }
  805. }
  806. } catch {
  807. // An unreadable or malformed intermediate manifest cannot own the
  808. // module; keep walking toward the declaring package root.
  809. }
  810. }
  811. const parent = dirname(dir)
  812. if (parent === dir) break
  813. dir = parent
  814. }
  815. return undefined
  816. }
  817. private sourceKey(loaderName: string, baseUrl: string): string {
  818. return `${baseUrl}\0${loaderName}`
  819. }
  820. /** Capture the bundle stats before reading its bytes. */
  821. private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
  822. const bundle = statSync(clientPath)
  823. return {
  824. path: clientPath,
  825. mtimeMs: bundle.mtimeMs,
  826. size: bundle.size,
  827. }
  828. }
  829. /** Allocate an opaque initial row revision without inspecting artifact bytes. */
  830. private allocateInitialRevision(): string {
  831. return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
  832. }
  833. /**
  834. * Read the activation-time bundle snapshot.
  835. * @param pkgName - package that declares the client bundle.
  836. * @param clientPath - absolute path of the built client artifact.
  837. * @returns the immutable bytes plus the pre-read filesystem baseline.
  838. * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
  839. */
  840. private initialBundleSnapshot(pkgName: string, clientPath: string): {
  841. bundle: Buffer
  842. baseline: ClientArtifactBaseline
  843. } {
  844. try {
  845. const baseline = this.captureArtifactBaseline(clientPath)
  846. const bundle = readFileSync(clientPath)
  847. return { bundle, baseline }
  848. } catch (error) {
  849. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  850. throw new MissingClientBundleError(pkgName, clientPath, error)
  851. }
  852. }
  853. /** Treat a missing, torn, or malformed development map as an identity section. */
  854. private readonly readSourceMap = (clientPath: string): Record<string, unknown> | undefined => {
  855. try {
  856. return readSourceMap(clientPath)
  857. } catch (error) {
  858. this.ctx.logger.warn(error)
  859. return undefined
  860. }
  861. }
  862. /** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */
  863. private processOne(entryName: string, onError: (err: Error) => void): boolean {
  864. const nextSources = new Map<string, ClientPackageSource>()
  865. for (const entry of this.ctx.loader.entries()) {
  866. if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue
  867. const source = this.resolveSource(entry)
  868. if (source !== undefined) nextSources.set(source.sourceKey, source)
  869. }
  870. const affectedPackages = new Set<string>()
  871. for (const [sourceKey, source] of this.sources) {
  872. if (source.loaderName !== entryName) continue
  873. affectedPackages.add(source.packageName)
  874. if (!nextSources.has(sourceKey)) this.sources.delete(sourceKey)
  875. }
  876. for (const [sourceKey, source] of nextSources) {
  877. affectedPackages.add(source.packageName)
  878. this.sources.set(sourceKey, source)
  879. }
  880. let changed = false
  881. for (const packageName of affectedPackages) {
  882. try {
  883. if (this.reconcilePackage(packageName)) changed = true
  884. } catch (error) {
  885. onError(error instanceof Error ? error : new Error(String(error)))
  886. }
  887. }
  888. return changed
  889. }
  890. private resolveSource(entry: Entry): ClientPackageSource | undefined {
  891. const loaderName = entry.options.name
  892. const baseUrl = entry.parent.tree.ctx.baseUrl
  893. if (baseUrl === undefined) {
  894. throw new Error(`client-modules: loader entry ${loaderName} has no resolution base URL`)
  895. }
  896. const resolved = this.resolveMeta(loaderName, baseUrl)
  897. if (resolved === null) return undefined
  898. return { ...resolved, loaderName, baseUrl, sourceKey: this.sourceKey(loaderName, baseUrl) }
  899. }
  900. private reconcilePackage(packageName: string): boolean {
  901. const sources: ClientPackageSource[] = []
  902. for (const source of this.sources.values()) {
  903. if (source.packageName === packageName) sources.push(source)
  904. }
  905. if (sources.length > 1) {
  906. const locations = sources
  907. .map(source => `${JSON.stringify(source.loaderName)} from ${source.baseUrl}`)
  908. .join(', ')
  909. throw new Error(
  910. `client-modules: package ${packageName} resolves from multiple active Loader sources: ${locations}; remove one entry`,
  911. )
  912. }
  913. const source = sources[0]
  914. if (source === undefined) return this.table.delete(packageName)
  915. if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
  916. // The opaque initial rev rides the row until HMR observes a file change;
  917. // a fiber restart from the same source reuses the existing row.
  918. const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
  919. const rev = this.allocateInitialRevision()
  920. this.table.set(packageName, {
  921. entry: graphRow(packageName, rev, source.meta),
  922. loaderName: source.loaderName,
  923. sourceKey: source.sourceKey,
  924. meta: source.meta,
  925. bundle: snapshot.bundle,
  926. baseline: snapshot.baseline,
  927. })
  928. return true
  929. }
  930. private flush(onError: (err: Error) => void): void {
  931. let changed = false
  932. for (const entryName of [...this.dirty]) {
  933. this.dirty.delete(entryName)
  934. try {
  935. if (this.processOne(entryName, onError)) changed = true
  936. } catch (error) {
  937. // Steady state: one broken package must not poison the others; the
  938. // activation pass aggregates these into a loud throw instead.
  939. onError(error instanceof Error ? error : new Error(String(error)))
  940. }
  941. }
  942. if (!changed) return
  943. let composed: WebBootGraph
  944. try {
  945. composed = this.compose()
  946. } catch (error) {
  947. // An unorderable module graph is a property of the whole table, not of
  948. // the arriving package, so it surfaces here: aggregated into the
  949. // activation throw, or warned in steady state while the last orderable
  950. // graph stays served.
  951. onError(error as Error)
  952. return
  953. }
  954. this.composed = composed
  955. this.notifyGraphChanged()
  956. }
  957. private async bundleResource(method: string | undefined, url: string): Promise<{
  958. status: number
  959. headers?: Record<string, string>
  960. body?: Buffer
  961. }> {
  962. if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
  963. const requestUrl = new URL(url, 'http://x')
  964. const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
  965. const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl)
  966. if (response !== undefined) {
  967. return {
  968. status: 200,
  969. headers: { 'content-type': response.contentType, 'cache-control': IMMUTABLE_CACHE },
  970. ...(method === 'HEAD' ? {} : { body: await response.body() }),
  971. }
  972. }
  973. // Anything else under /plugins (including unadvertised combinations and
  974. // /plugins/events when the HMR row is absent) is an unknown resource.
  975. return { status: 404 }
  976. }
  977. private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
  978. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
  979. const response = await this.bundleResource(req.method, req.url ?? '/')
  980. res.writeHead(response.status, response.headers)
  981. res.end(response.body)
  982. }
  983. }
  984. export default ClientModuleRegistry