index.ts 42 KB

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