loader.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. /**
  2. * ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
  3. * seam. The conceptual contract (lazy CJS model, resolution branch order) is
  4. * documented on the package module and the public interfaces in `./index.ts`;
  5. * this file owns the state tables and the fetch/execute/materialize machinery.
  6. */
  7. import type {
  8. ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
  9. ClientPluginHandoff, DshWindow, WebBootEntry,
  10. } from './index.ts'
  11. /** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
  12. interface RegisteredFactory {
  13. factory: ClientPluginHandoff['factory']
  14. url: string
  15. }
  16. /** Default bundle fetch seam: same-origin fetch().text(). */
  17. const defaultFetchBundle = async (url: string): Promise<string> => {
  18. const res = await fetch(url)
  19. if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
  20. return res.text()
  21. }
  22. /** Default bundle execution seam: a <script> element carrying the code. */
  23. const defaultExecuteBundle = (code: string, url: string): void => {
  24. const el = document.createElement('script')
  25. // Inline execution (not src) so the fetch half stays parallelizable; the
  26. // sourceURL comment keeps devtools stack frames attributed to the bundle.
  27. el.textContent = `${code}\n//# sourceURL=${url}`
  28. document.head.appendChild(el)
  29. // Execution is synchronous for inline scripts: the factory is registered by
  30. // now, so the node (and its source text) has no further job. Removing it
  31. // keeps repeated HMR rebuilds from accumulating dead script nodes.
  32. el.remove()
  33. }
  34. const urlOf = (row: WebBootEntry): string => {
  35. // url is conditional on the wire (shell-own pseudo rows omit it); those
  36. // ids resolve through the static registry and never reach a fetch.
  37. if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
  38. return row.url
  39. }
  40. /**
  41. * A plugin bundle IS its package's client half: `<id>/client` (the exports
  42. * subpath external bundles emit) and the bare graph id name the same
  43. * surface, so table lookups normalize the suffix away.
  44. */
  45. const stripClientSuffix = (spec: string): string =>
  46. spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
  47. /**
  48. * Claim and inventory the <style> tags a factory injected during
  49. * materialization: preset-emitted tags arrive pre-tagged with data-plugin;
  50. * any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
  51. */
  52. const claimStyles = (id: string): string[] => {
  53. if (typeof document === 'undefined') return []
  54. for (const el of document.querySelectorAll('style:not([data-plugin])')) {
  55. el.setAttribute('data-plugin', id)
  56. }
  57. const owned: string[] = []
  58. for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
  59. owned.push(el.getAttribute('data-plugin-css') ?? id)
  60. }
  61. return owned
  62. }
  63. /**
  64. * The client module system: state tables plus the arrival/materialization
  65. * machinery implementing {@link ClientModuleLoader} (whose members carry the
  66. * seam contract docs). Construction indexes the boot graph and installs the
  67. * `window.__ModuleLoader__` registration sink (contract C6) — once per page.
  68. */
  69. export class ClientModuleLoaderImpl implements ClientModuleLoader {
  70. readonly version = 'client'
  71. readonly loadCache = new Map<string, ClientModuleRecord>()
  72. private readonly seed: Map<string, unknown>
  73. private readonly statics = new Map<string, unknown>()
  74. private readonly factories = new Map<string, RegisteredFactory>()
  75. /** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
  76. private readonly pendingArrival = new Map<string, Promise<void>>()
  77. /** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
  78. private readonly materializing = new Set<string>()
  79. private readonly graphRows = new Map<string, WebBootEntry>()
  80. // Execution URL of the bundle currently being executed (bound into the
  81. // factory registration so diagnostics can name the source).
  82. private executingUrl = ''
  83. // Graph id of the row currently being executed ('' outside arrive):
  84. // the load sink cross-checks the handoff id against it so a mis-stamped
  85. // bundle cannot register under another entry's identity.
  86. private executingId = ''
  87. private readonly fetchBundle: (url: string) => Promise<string>
  88. private readonly executeBundle: (code: string, url: string) => void
  89. /**
  90. * Build the module system over the host graph.
  91. * @param options - entry graph, module-table staticModules, fetch/execute seams.
  92. */
  93. constructor(options: ClientModuleLoaderOptions) {
  94. this.seed = new Map(Object.entries(options.staticModules))
  95. this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
  96. this.executeBundle = options.executeBundle ?? defaultExecuteBundle
  97. for (const entry of options.graph.entries) {
  98. if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
  99. this.graphRows.set(entry.id, entry)
  100. }
  101. const win = globalThis as DshWindow
  102. if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
  103. win.__ModuleLoader__ = {
  104. load: (handoff: ClientPluginHandoff): void => {
  105. // Registration is keyed by the handoff id; a duplicate means a bundle
  106. // executed twice without an invalidate — always a bug, always loud.
  107. if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
  108. // A fetched row's bundle must register the id its row names — a
  109. // mis-stamped bundle registering under another entry's identity
  110. // would let that entry silently materialize foreign exports.
  111. if (this.executingId !== '' && handoff.id !== this.executingId) {
  112. throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
  113. }
  114. this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
  115. },
  116. }
  117. }
  118. /** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
  119. private arrive(row: WebBootEntry): Promise<void> {
  120. const { id } = row
  121. const pending = this.pendingArrival.get(id)
  122. if (pending !== undefined) return pending
  123. if (this.factories.has(id)) return Promise.resolve()
  124. const task = (async (): Promise<void> => {
  125. const url = urlOf(row)
  126. const code = await this.fetchBundle(url)
  127. this.executingUrl = url
  128. this.executingId = id
  129. try {
  130. this.executeBundle(code, url)
  131. } finally {
  132. this.executingUrl = ''
  133. this.executingId = ''
  134. }
  135. if (!this.factories.has(id)) {
  136. throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
  137. }
  138. })().finally(() => { this.pendingArrival.delete(id) })
  139. this.pendingArrival.set(id, task)
  140. return task
  141. }
  142. /** Materialize a registered factory (synchronous; memoized in loadCache). */
  143. private materialize(id: string): ClientModuleRecord {
  144. const existing = this.loadCache.get(id)
  145. if (existing !== undefined) return existing
  146. const registered = this.factories.get(id)
  147. /* v8 ignore next -- callers check the factory branch before dispatching here. */
  148. if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
  149. if (this.materializing.has(id)) {
  150. throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
  151. }
  152. this.materializing.add(id)
  153. try {
  154. const edges = new Set<string>()
  155. const surface = registered.factory(this.makeRequire(edges))
  156. const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
  157. this.loadCache.set(id, record)
  158. return record
  159. } finally {
  160. this.materializing.delete(id)
  161. }
  162. }
  163. /**
  164. * The synchronous require answered to factories: seed → static → memoized
  165. * record → registered factory (recursive materialization — this is what
  166. * makes load order self-resolving). Fetching is async and therefore
  167. * unreachable from here; an unregistered plugin specifier is loud (and a
  168. * cross-plugin value import is already a build error upstream).
  169. */
  170. private makeRequire(edges: Set<string>): (spec: string) => unknown {
  171. return (spec: string): unknown => {
  172. edges.add(spec)
  173. if (this.seed.has(spec)) return this.seed.get(spec)
  174. if (this.statics.has(spec)) return this.statics.get(spec)
  175. const id = stripClientSuffix(spec)
  176. const record = this.loadCache.get(id)
  177. if (record !== undefined) return record.surface
  178. if (this.factories.has(id)) return this.materialize(id).surface
  179. throw new Error(
  180. `client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
  181. + 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
  182. )
  183. }
  184. }
  185. async import(specifier: string): Promise<unknown> {
  186. if (this.seed.has(specifier)) return this.seed.get(specifier)
  187. const existing = this.loadCache.get(specifier)
  188. if (existing !== undefined) return existing.surface
  189. if (this.statics.has(specifier)) {
  190. const surface = this.statics.get(specifier)
  191. this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
  192. return surface
  193. }
  194. if (!this.factories.has(specifier)) {
  195. const row = this.graphRows.get(specifier)
  196. if (row === undefined) {
  197. throw new Error(
  198. `client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
  199. + 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
  200. )
  201. }
  202. await this.arrive(row)
  203. }
  204. return this.materialize(specifier).surface
  205. }
  206. registerStatic(id: string, module: unknown): void {
  207. if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
  208. this.statics.set(id, module)
  209. }
  210. async prefetch(id: string): Promise<void> {
  211. if (this.statics.has(id)) return
  212. const row = this.graphRows.get(id)
  213. if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
  214. await this.arrive(row)
  215. }
  216. invalidate(id: string): void {
  217. this.factories.delete(id)
  218. this.loadCache.delete(id)
  219. }
  220. }