node-half.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /**
  2. * Node half of the HMR plugin: bundle watches follow the graph, stat changes
  3. * report through clientModuleHost.rebuilt, and everything dies with the fiber.
  4. */
  5. import { EventEmitter } from 'node:events'
  6. import type { ServerResponse, IncomingMessage } from 'node:http'
  7. import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import Loader from '@deepseek-ai/cordis-plugin-loader'
  11. import { Context } from '@deepseek-ai/cordis'
  12. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  13. import type { ClientArtifactBaseline, ClientModuleRegistry, WebBootGraph } from '@deepseek-ai/dsh-client-modules'
  14. import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
  15. import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
  16. const POLL_MS = 20
  17. let dir: string
  18. beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
  19. afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
  20. /**
  21. * Controllable clientModuleHost fake over a mutable id → bundle-path table.
  22. * Structural (Pick+cast): the plugin only touches the read/notify surface;
  23. * the service class carries private scan state a literal need not reproduce.
  24. */
  25. type FakeHost = ClientModuleRegistry & { rebuiltCalls: string[]; fireGraphChanged(): void }
  26. interface FakeHostOptions {
  27. beforeGraphRead?: () => void
  28. rebuilt?: (id: string) => string | undefined
  29. }
  30. function artifactBaseline(path: string): ClientArtifactBaseline {
  31. const bundle = statSync(path)
  32. return { path, mtimeMs: bundle.mtimeMs, size: bundle.size }
  33. }
  34. function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
  35. const graphListeners = new Set<() => void>()
  36. const rebuiltCalls: string[] = []
  37. const baselines = new Map([...rows].map(([id, path]) => [id, artifactBaseline(path)]))
  38. const fake: Pick<FakeHost, 'graph' | 'artifactBaseline' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
  39. rebuiltCalls,
  40. fireGraphChanged: () => { for (const l of graphListeners) l() },
  41. graph: (): WebBootGraph => {
  42. options.beforeGraphRead?.()
  43. return {
  44. rev: 'r',
  45. entries: [...rows.keys()].map(id => ({ id, url: `/plugins/??${id}/client.js&rev=r`, rev: 'r' })),
  46. batches: [],
  47. }
  48. },
  49. artifactBaseline: (id) => {
  50. const path = rows.get(id)
  51. if (path === undefined) return undefined
  52. let baseline = baselines.get(id)
  53. if (baseline?.path !== path) {
  54. baseline = artifactBaseline(path)
  55. baselines.set(id, baseline)
  56. }
  57. return { ...baseline }
  58. },
  59. rebuilt: (id) => {
  60. rebuiltCalls.push(id)
  61. return options.rebuilt?.(id) ?? 'r2'
  62. },
  63. onRebuilt: () => () => {},
  64. onGraphChanged: (listener) => {
  65. graphListeners.add(listener)
  66. return () => { graphListeners.delete(listener) }
  67. },
  68. }
  69. return fake as FakeHost
  70. }
  71. // Structural fake: the plugin only touches register(); the service class
  72. // carries private state a literal cannot (and need not) reproduce.
  73. function fakeHttpServer(routes: WebRoute[]): WebServer {
  74. const fake: Pick<WebServer, 'register' | 'tapIndex' | 'port'> = {
  75. register(route) {
  76. routes.push(route)
  77. return () => { routes.splice(routes.indexOf(route), 1) }
  78. },
  79. tapIndex: () => () => {},
  80. port: 0,
  81. }
  82. return fake as WebServer
  83. }
  84. async function mount(clientModuleHost: FakeHost, webServer: WebServer) {
  85. const ctx = new Context()
  86. ctx.provide('clientModules', clientModuleHost)
  87. ctx.provide('webServer', webServer)
  88. const fiber = ctx.plugin(
  89. { inject: [...inject], Config, apply },
  90. { pollIntervalMs: POLL_MS },
  91. )
  92. await fiber.await()
  93. return fiber
  94. }
  95. describe('hmr node half', () => {
  96. it('watches graph bundles, ignores map-only changes, and unwatches on dispose', async () => {
  97. const bundle = join(dir, 'a.js')
  98. writeFileSync(bundle, 'v1')
  99. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
  100. const routes: WebRoute[] = []
  101. const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
  102. expect(routes).toHaveLength(1)
  103. expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
  104. expect(clientModuleHost.rebuiltCalls).toEqual([])
  105. // Nudge mtime past stat granularity so the poller sees a content signal.
  106. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  107. writeFileSync(bundle, 'v2-longer')
  108. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
  109. clientModuleHost.rebuiltCalls.length = 0
  110. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  111. writeFileSync(`${bundle}.map`, '{"version":3}')
  112. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  113. expect(clientModuleHost.rebuiltCalls).toEqual([])
  114. writeFileSync(bundle, 'v3-even-longer')
  115. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
  116. await fiber.dispose()
  117. expect(routes).toHaveLength(0)
  118. // Watcher gone: further file changes report nothing.
  119. clientModuleHost.rebuiltCalls.length = 0
  120. writeFileSync(bundle, 'v4-after-dispose')
  121. await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
  122. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  123. })
  124. it('follows graph changes: rows added after activation get watched', async () => {
  125. const early = join(dir, 'early.js')
  126. const late = join(dir, 'late.js')
  127. writeFileSync(early, 'v1')
  128. const rows = new Map([['pkg-early', early]])
  129. const clientModuleHost = fakeClientModuleHost(rows)
  130. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  131. clientModuleHost.rebuiltCalls.length = 0
  132. writeFileSync(late, 'v1')
  133. rows.set('pkg-late', late)
  134. clientModuleHost.fireGraphChanged()
  135. expect(clientModuleHost.rebuiltCalls).toEqual([])
  136. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  137. writeFileSync(late, 'v2-longer')
  138. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
  139. rows.delete('pkg-late')
  140. clientModuleHost.fireGraphChanged()
  141. clientModuleHost.rebuiltCalls.length = 0
  142. writeFileSync(late, 'v3-even-longer')
  143. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  144. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  145. await fiber.dispose()
  146. })
  147. it('rehashes only a row changed between its startup snapshot and watch installation', async () => {
  148. const bundle = join(dir, 'construction.js')
  149. writeFileSync(bundle, 'v1')
  150. let rewrite = true
  151. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
  152. beforeGraphRead: () => {
  153. if (!rewrite) return
  154. rewrite = false
  155. writeFileSync(bundle, 'v2-written-during-watch-construction')
  156. },
  157. })
  158. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  159. expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
  160. clientModuleHost.rebuiltCalls.length = 0
  161. await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
  162. expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
  163. await fiber.dispose()
  164. })
  165. it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
  166. const bundle = join(dir, 'replace.js')
  167. writeFileSync(bundle, 'seed')
  168. const fixedTime = new Date(1_600_000_000_000)
  169. utimesSync(bundle, fixedTime, fixedTime)
  170. const baseline = statSync(bundle)
  171. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
  172. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  173. clientModuleHost.rebuiltCalls.length = 0
  174. unlinkSync(bundle)
  175. await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
  176. writeFileSync(bundle, 'x'.repeat(baseline.size))
  177. utimesSync(bundle, fixedTime, fixedTime)
  178. const restored = statSync(bundle)
  179. expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
  180. mtimeMs: baseline.mtimeMs,
  181. size: baseline.size,
  182. })
  183. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
  184. await fiber.dispose()
  185. })
  186. it('retains a dirty baseline when a catch-up re-hash races a rename', async () => {
  187. const bundle = join(dir, 'rename.js')
  188. writeFileSync(bundle, 'v1')
  189. let first = true
  190. const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
  191. beforeGraphRead: () => {
  192. if (!first) return
  193. writeFileSync(bundle, 'v2-written-during-watch-construction')
  194. },
  195. rebuilt: () => {
  196. if (!first) return 'r2'
  197. first = false
  198. throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
  199. },
  200. })
  201. const fiber = await mount(clientModuleHost, fakeHttpServer([]))
  202. await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
  203. await fiber.dispose()
  204. })
  205. })
  206. it('broadcasts the desired graph without waiting for Host activation or cleanup', async () => {
  207. const ctx = new Context()
  208. await ctx.plugin(Loader)
  209. const bundle = join(dir, 'a.js')
  210. writeFileSync(bundle, 'a')
  211. const rows = new Map([['a', bundle]])
  212. const host = fakeClientModuleHost(rows)
  213. const routes: WebRoute[] = []
  214. ctx.provide('clientModules', host)
  215. ctx.provide('webServer', fakeHttpServer(routes))
  216. let release!: () => void
  217. let cleaned!: () => void
  218. let started!: () => void
  219. const starting = new Promise<void>((resolve) => { started = resolve })
  220. const activation = new Promise<void>((resolve) => { release = resolve })
  221. const cleanup = new Promise<void>((resolve) => { cleaned = resolve })
  222. let disposed = false
  223. ctx.loader.internal = { version: 'client', import: async () => ({
  224. apply: async (pluginCtx: Context) => {
  225. pluginCtx.effect(() => async () => { await cleanup; disposed = true })
  226. started()
  227. await activation
  228. },
  229. }) } as never
  230. const entryId = await ctx.loader.create({ name: 'owned' })
  231. await starting
  232. const fiber = ctx.plugin({ inject, Config, apply }, { pollIntervalMs: POLL_MS })
  233. await fiber.await()
  234. const route = routes[0]!
  235. const connect = async () => {
  236. const lines: string[] = []
  237. const response = Object.assign(new EventEmitter(), {
  238. writeHead: vi.fn(), write: (line: string) => { lines.push(line) },
  239. destroy: vi.fn(), end: vi.fn(),
  240. })
  241. await route.handler({ method: 'GET' } as IncomingMessage, response as unknown as ServerResponse)
  242. return { lines, response }
  243. }
  244. try {
  245. const first = await connect()
  246. expect(first.lines).toHaveLength(2)
  247. const frame = JSON.parse(first.lines[1]!.slice(6)) as { graph: WebBootGraph }
  248. expect(frame.graph.entries.map(row => row.id)).toEqual(['a'])
  249. const second = await connect()
  250. expect(second.lines[1]).toBe(first.lines[1])
  251. expect(first.lines).toHaveLength(2)
  252. release()
  253. const owned = ctx.loader.resolve(entryId).fiber!
  254. await owned.await()
  255. const child = owned.ctx.plugin({ apply() {} })
  256. await child.await()
  257. expect(child.entry).toBe(owned.entry)
  258. await child.dispose()
  259. await child.await()
  260. await ctx.loader.await()
  261. await new Promise<void>(resolve => setImmediate(resolve))
  262. expect(second.lines).toHaveLength(2)
  263. first.response.emit('close')
  264. ctx.loader.remove(entryId)
  265. rows.clear()
  266. host.fireGraphChanged()
  267. expect(second.lines).toHaveLength(3)
  268. expect(disposed).toBe(false)
  269. expect((JSON.parse(second.lines[2]!.slice(6)) as { graph: WebBootGraph }).graph.entries).toEqual([])
  270. const third = await connect()
  271. expect(third.lines[1]).toBe(second.lines[2])
  272. expect(second.lines).toHaveLength(3)
  273. cleaned()
  274. while (owned.inertia !== undefined) await owned.inertia
  275. expect(disposed).toBe(true)
  276. expect(second.lines).toHaveLength(3)
  277. await fiber.dispose()
  278. host.fireGraphChanged()
  279. expect(second.lines).toHaveLength(3)
  280. expect(second.response.destroy).toHaveBeenCalledOnce()
  281. expect(third.response.destroy).toHaveBeenCalledOnce()
  282. } finally {
  283. release()
  284. cleaned()
  285. await fiber.dispose()
  286. await ctx.fiber.dispose()
  287. }
  288. })