node-half.client.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. /** Node-half composition diagnostics for package metadata and built client bundles. */
  2. import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  3. import type { IncomingMessage, ServerResponse } from 'node:http'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { runInNewContext } from 'node:vm'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
  11. import * as modulesClient from '../src/client/index.ts'
  12. import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts'
  13. import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
  14. const MODULES_ID = '@deepseek-ai/dsh-client-modules'
  15. const RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
  16. let root: string | undefined
  17. afterEach(() => {
  18. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  19. root = undefined
  20. })
  21. /** Create a resolvable package whose client export points at the returned path. */
  22. function writePackage(
  23. packageName: string,
  24. metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
  25. ): string {
  26. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  27. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  28. const clientPath = join(pkgRoot, 'lib', 'client.js')
  29. mkdirSync(pkgRoot, { recursive: true })
  30. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  31. name: packageName,
  32. exports: {
  33. './client': './lib/client.js',
  34. './package.json': './package.json',
  35. },
  36. ...metadata,
  37. }))
  38. return clientPath
  39. }
  40. /** Create a built package with the supplied client declaration. */
  41. function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
  42. const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
  43. mkdirSync(dirname(clientPath), { recursive: true })
  44. writeFileSync(clientPath, 'module.exports = {}\n')
  45. }
  46. /** Construct the node-half service and capture its plugin-bundle route. */
  47. function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
  48. const ctx = new Context()
  49. ctx.baseUrl = pathToFileURL(root!).href + '/'
  50. ctx.provide('loader', {
  51. *entries() {
  52. for (const packageName of packageNames) {
  53. yield { options: { name: packageName }, fiber: {}, disabled: false }
  54. }
  55. },
  56. })
  57. let route: WebRoute | undefined
  58. const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
  59. port: 0,
  60. register: (candidate) => {
  61. if (candidate.path === '/plugins') route = candidate
  62. return () => {}
  63. },
  64. tapIndex: () => () => {},
  65. }
  66. ctx.provide('webServer', webServer as WebServer)
  67. const service = new ClientModuleRegistry(ctx)
  68. if (route === undefined) throw new Error('client bundle route was not registered')
  69. return { service, route }
  70. }
  71. /** Construct the node-half service over the enabled fixture entries. */
  72. function construct(packageNames: string[]): ClientModuleRegistry {
  73. return constructWithRoute(packageNames).service
  74. }
  75. /** Execute the exact first inline script emitted by the Host HTML transform. */
  76. function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
  77. const html = injectBootManifest('<html><head></head><body><script type="module" src="/index.js"></script></body></html>', graph)
  78. const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
  79. if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
  80. const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
  81. runInNewContext(source, { window })
  82. if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
  83. return { html, target: window.__ModuleLoader__ }
  84. }
  85. const bootGraph = (): WebBootGraph => ({
  86. rev: 'graph',
  87. entries: [
  88. { id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
  89. { id: RUNTIME_ID, url: '/plugins/runtime.js?rev=r', rev: 'r' },
  90. ],
  91. })
  92. describe('HTML bootstrap facade', () => {
  93. it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
  94. const graph = bootGraph()
  95. const { html, target } = injectedFacade(graph)
  96. const facadeAt = html.indexOf('window.__ModuleLoader__=')
  97. const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
  98. const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
  99. const graphAt = html.indexOf('window.__DSH_BOOT__ = ')
  100. const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
  101. expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
  102. facadeAt, modulesAt, runtimeAt, graphAt, entryAt,
  103. ])].sort((a, b) => a - b))
  104. target.load({ id: MODULES_ID, factory: () => modulesClient })
  105. target.load({ id: RUNTIME_ID, factory: () => ({ marker: 'runtime' }) })
  106. const system = target.create({ boot: graph, staticModules: {} })
  107. expect(target.mode).toBe('live')
  108. expect(target.pendingQueue).toEqual([])
  109. expect(system.manifest.rev).toBe('graph')
  110. expect(await system.import(MODULES_ID)).toBe(modulesClient)
  111. expect(await system.import(`${RUNTIME_ID}/client`)).toEqual({ marker: 'runtime' })
  112. expect(() => target.create({ boot: graph, staticModules: {} }))
  113. .toThrow('create called after module-system boot')
  114. })
  115. it('rejects a page that did not preload the modules bundle', () => {
  116. const graph = bootGraph()
  117. const { target } = injectedFacade(graph)
  118. expect(() => target.create({ boot: graph, staticModules: {} }))
  119. .toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
  120. })
  121. it('rejects a bootstrap bundle with a runtime external', () => {
  122. const graph = bootGraph()
  123. const { target } = injectedFacade(graph)
  124. target.load({
  125. id: MODULES_ID,
  126. factory: (require) => {
  127. require('react')
  128. return modulesClient
  129. },
  130. })
  131. expect(() => target.create({ boot: graph, staticModules: {} }))
  132. .toThrow(`${MODULES_ID}/client.js requested external "react"`)
  133. })
  134. it.each([
  135. null,
  136. { ...modulesClient, createClientModuleSystem: undefined },
  137. { ...modulesClient, apply: undefined },
  138. ])('rejects a bootstrap bundle without the complete module face', (exports) => {
  139. const graph = bootGraph()
  140. const { target } = injectedFacade(graph)
  141. target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
  142. expect(() => target.create({ boot: graph, staticModules: {} }))
  143. .toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
  144. })
  145. })
  146. describe('client bundle activation', () => {
  147. it('allows sibling dsh roles', () => {
  148. const currentName = '@fixture/current-client-field'
  149. const clientPath = writePackage(currentName, {
  150. dsh: {
  151. bundle: { patch: './cordis.patch.yml' },
  152. client: { platform: 'web' },
  153. profile: { bundles: [] },
  154. },
  155. })
  156. mkdirSync(dirname(clientPath), { recursive: true })
  157. writeFileSync(clientPath, 'module.exports = {}\n')
  158. expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
  159. })
  160. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  161. const firstName = '@fixture/missing-first'
  162. const secondName = '@fixture/missing-second'
  163. const firstPath = writePackage(firstName)
  164. const secondPath = writePackage(secondName)
  165. expect(() => construct([firstName, secondName])).toThrow([
  166. 'client-modules: 2 client packages failed to compose:',
  167. ' client bundles not found; run `pnpm run build` before launch:',
  168. ` - package: ${firstName}`,
  169. ` path: ${firstPath}`,
  170. ` - package: ${secondName}`,
  171. ` path: ${secondPath}`,
  172. ].join('\n'))
  173. })
  174. it('does not report other bundle read failures as missing builds', () => {
  175. const packageName = '@fixture/unreadable-client'
  176. const clientPath = writePackage(packageName)
  177. mkdirSync(clientPath, { recursive: true })
  178. let thrown: unknown
  179. try {
  180. construct([packageName])
  181. } catch (error) {
  182. thrown = error
  183. }
  184. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  185. expect(String(thrown)).toContain(' other failures:')
  186. expect(String(thrown)).toContain('EISDIR')
  187. expect(String(thrown)).not.toContain('pnpm run build')
  188. })
  189. it('serves the source map beside a registered client bundle', async () => {
  190. const packageName = '@fixture/source-map'
  191. const clientPath = writePackage(packageName)
  192. mkdirSync(dirname(clientPath), { recursive: true })
  193. writeFileSync(clientPath, 'module.exports = {}\n')
  194. const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
  195. writeFileSync(`${clientPath}.map`, map)
  196. const { route } = constructWithRoute([packageName])
  197. let status = 0
  198. let headers: Record<string, string> | undefined
  199. let body = ''
  200. const response = {
  201. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  202. status = nextStatus
  203. headers = nextHeaders
  204. return response
  205. },
  206. end(chunk?: Uint8Array) {
  207. body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
  208. return response
  209. },
  210. } as unknown as ServerResponse
  211. await route.handler({
  212. method: 'GET',
  213. url: `/plugins/${packageName}/client.js.map`,
  214. } as IncomingMessage, response)
  215. expect(status).toBe(200)
  216. expect(headers).toEqual({
  217. 'content-type': 'application/json; charset=utf-8',
  218. 'cache-control': 'no-cache',
  219. })
  220. expect(body).toBe(map)
  221. })
  222. })
  223. describe('shared module declarations', () => {
  224. it('accepts external requests and carries them onto the graph row', () => {
  225. const packageName = '@fixture/shared-declared'
  226. writeBuiltPackage(packageName, { external: ['react'] })
  227. expect(construct([packageName]).graph().entries).toEqual([{
  228. id: packageName,
  229. url: expect.stringContaining(`/plugins/${packageName}/client.js?rev=`) as unknown as string,
  230. rev: expect.any(String) as unknown as string,
  231. external: ['react'],
  232. }])
  233. })
  234. it('omits external when the package declares no requests', () => {
  235. const packageName = '@fixture/shared-absent'
  236. writeBuiltPackage(packageName, {})
  237. const [row] = construct([packageName]).graph().entries
  238. expect(row).not.toHaveProperty('external')
  239. })
  240. it('rejects a non-array external', () => {
  241. const packageName = '@fixture/external-not-array'
  242. writeBuiltPackage(packageName, { external: 'react' })
  243. expect(() => construct([packageName]))
  244. .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
  245. })
  246. })
  247. describe('module graph order', () => {
  248. const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
  249. ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', ...fields })
  250. const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
  251. it('places every requested package row before its consumers along a chain', () => {
  252. expect(ids(orderByModuleGraph([
  253. entry('ui', { external: ['slots'] }),
  254. entry('slots', { external: ['render'] }),
  255. entry('render'),
  256. ]))).toEqual(['render', 'slots', 'ui'])
  257. })
  258. it('places a shared package row before both arms of a diamond', () => {
  259. expect(ids(orderByModuleGraph([
  260. entry('app', { external: ['left', 'right'] }),
  261. entry('left', { external: ['vendor'] }),
  262. entry('right', { external: ['vendor'] }),
  263. entry('vendor'),
  264. ]))).toEqual(['vendor', 'left', 'right', 'app'])
  265. })
  266. it('resolves a /client request onto the requested package row', () => {
  267. expect(ids(orderByModuleGraph([
  268. entry('ui', { external: ['runtime/client'] }),
  269. entry('runtime'),
  270. ]))).toEqual(['runtime', 'ui'])
  271. })
  272. it('leaves a request no row answers to the static assembly channel', () => {
  273. expect(ids(orderByModuleGraph([
  274. entry('consumer', { external: ['@deepseek-ai/cordis'] }),
  275. entry('other'),
  276. ]))).toEqual(['consumer', 'other'])
  277. })
  278. it('rejects a cycle and names the packages on it', () => {
  279. expect(() => orderByModuleGraph([
  280. entry('a', { external: ['b'] }),
  281. entry('b', { external: ['a'] }),
  282. ])).toThrow('client-modules: module graph cycle a -> b -> a')
  283. })
  284. it('rejects a row requesting its own package name', () => {
  285. expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
  286. .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
  287. })
  288. it('composes the served graph in module-graph order', () => {
  289. const consumerName = '@fixture/order-consumer'
  290. const dependencyName = '@fixture/order-dependency'
  291. writeBuiltPackage(consumerName, { external: [dependencyName] })
  292. writeBuiltPackage(dependencyName, {})
  293. expect(ids(construct([consumerName, dependencyName]).graph().entries))
  294. .toEqual([dependencyName, consumerName])
  295. })
  296. it('fails activation loud when scanned packages form a module cycle', () => {
  297. writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
  298. writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
  299. expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
  300. .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
  301. })
  302. })