node-half.client.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. /** Node-half composition diagnostics for package metadata and built client bundles. */
  2. import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import type { IncomingMessage, ServerResponse } from 'node:http'
  4. import { SourceMap } from 'node:module'
  5. import { tmpdir } from 'node:os'
  6. import { dirname, join } from 'node:path'
  7. import { pathToFileURL } from 'node:url'
  8. import { runInNewContext } from 'node:vm'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { afterEach, describe, expect, it } from 'vitest'
  11. import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
  12. import * as modulesClient from '../src/client/index.ts'
  13. import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
  14. import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
  15. const MODULES_ID = '@deepseek-ai/dsh-client-modules'
  16. const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
  17. const comboUrl = (ids: readonly string[], rev: string): string =>
  18. `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
  19. const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
  20. const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
  21. const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
  22. let root: string | undefined
  23. afterEach(() => {
  24. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  25. root = undefined
  26. })
  27. /** Create a resolvable package whose client export points at the returned path. */
  28. function writePackage(
  29. packageName: string,
  30. metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
  31. ): string {
  32. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  33. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  34. const clientPath = join(pkgRoot, 'lib', 'client.js')
  35. mkdirSync(pkgRoot, { recursive: true })
  36. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  37. name: packageName,
  38. exports: {
  39. './client': './lib/client.js',
  40. './package.json': './package.json',
  41. },
  42. ...metadata,
  43. }))
  44. return clientPath
  45. }
  46. /** Create a built package with the supplied client declaration. */
  47. function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
  48. const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
  49. mkdirSync(dirname(clientPath), { recursive: true })
  50. writeFileSync(clientPath, 'module.exports = {}\n')
  51. }
  52. /** Construct the node-half service and capture its plugin-bundle route. */
  53. function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
  54. const ctx = new Context()
  55. ctx.baseUrl = pathToFileURL(root!).href + '/'
  56. ctx.provide('loader', {
  57. *entries() {
  58. for (const packageName of packageNames) {
  59. yield { options: { name: packageName }, fiber: {}, disabled: false }
  60. }
  61. },
  62. })
  63. let route: WebRoute | undefined
  64. const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
  65. port: 0,
  66. register: (candidate) => {
  67. if (candidate.path === '/plugins') route = candidate
  68. return () => {}
  69. },
  70. tapIndex: () => () => {},
  71. }
  72. ctx.provide('webServer', webServer as WebServer)
  73. const service = new ClientModuleRegistry(ctx)
  74. if (route === undefined) throw new Error('client bundle route was not registered')
  75. return { service, route }
  76. }
  77. /** Construct the node-half service over the enabled fixture entries. */
  78. function construct(packageNames: string[]): ClientModuleRegistry {
  79. return constructWithRoute(packageNames).service
  80. }
  81. /** Invoke the registered plugin route and capture status, headers, and bytes. */
  82. async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{
  83. status: number
  84. headers: Record<string, string> | undefined
  85. body: Buffer
  86. }> {
  87. let status = 0
  88. let headers: Record<string, string> | undefined
  89. let body = Buffer.alloc(0)
  90. const response = {
  91. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  92. status = nextStatus
  93. headers = nextHeaders
  94. return response
  95. },
  96. end(chunk?: Uint8Array) {
  97. body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
  98. return response
  99. },
  100. } as unknown as ServerResponse
  101. await route.handler({ method, url } as IncomingMessage, response)
  102. return { status, headers, body }
  103. }
  104. /** Execute the exact first inline script emitted by the Host boot rows. */
  105. function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
  106. const html = renderIndexInjections(
  107. '<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
  108. bootInjections(graph),
  109. )
  110. const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
  111. if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
  112. const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
  113. runInNewContext(source, { window })
  114. if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
  115. return { html, target: window.__ModuleLoader__ }
  116. }
  117. const bootGraph = (): WebBootGraph => ({
  118. rev: 'graph',
  119. entries: [
  120. { id: MODULES_ID, url: comboUrl([MODULES_ID], 'm'), rev: 'm' },
  121. { id: UI_RENDERER_ID, url: comboUrl([UI_RENDERER_ID], 'r'), rev: 'r' },
  122. ],
  123. batches: [
  124. {
  125. phase: 'bootstrap',
  126. url: BOOTSTRAP_URL,
  127. rev: 'boot',
  128. entries: [MODULES_ID],
  129. },
  130. {
  131. phase: 'application',
  132. url: APPLICATION_URL,
  133. rev: 'app',
  134. entries: [UI_RENDERER_ID],
  135. },
  136. ],
  137. })
  138. describe('HTML bootstrap facade', () => {
  139. it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
  140. const graph = bootGraph()
  141. const { html, target } = injectedFacade(graph)
  142. const facadeAt = html.indexOf('window.__ModuleLoader__=')
  143. const applicationAt = html.indexOf(
  144. `<link rel="preload" as="script" href="${APPLICATION_URL.replaceAll('&', '&amp;')}">`,
  145. )
  146. const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL.replaceAll('&', '&amp;')}"></script>`)
  147. const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
  148. const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
  149. expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
  150. facadeAt, applicationAt, bootstrapAt, graphAt, entryAt,
  151. ])].sort((a, b) => a - b))
  152. target.load({ id: MODULES_ID, factory: () => modulesClient })
  153. const system = target.create({
  154. boot: graph,
  155. staticModules: {},
  156. loadBundle: async (url) => {
  157. expect(url).toBe(APPLICATION_URL)
  158. target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
  159. },
  160. })
  161. expect(target.mode).toBe('live')
  162. expect(target.pendingQueue).toEqual([])
  163. expect(system.manifest.rev).toBe('graph')
  164. expect(await system.import(MODULES_ID)).toBe(modulesClient)
  165. expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' })
  166. expect(() => target.create({ boot: graph, staticModules: {} }))
  167. .toThrow('create called after module-system boot')
  168. })
  169. it('preloads every application combo', () => {
  170. const graph = bootGraph()
  171. const secondId = '@fixture/second-application-combo'
  172. const secondUrl = comboUrl([secondId], 'app-2')
  173. graph.entries.push({ id: secondId, url: comboUrl([secondId], 'row-2'), rev: 'row-2' })
  174. graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
  175. expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
  176. .toEqual([APPLICATION_URL, secondUrl])
  177. })
  178. it('rejects a page that did not preload the modules bundle', () => {
  179. const graph = bootGraph()
  180. const { target } = injectedFacade(graph)
  181. expect(() => target.create({ boot: graph, staticModules: {} }))
  182. .toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
  183. })
  184. it('rejects a bootstrap bundle with a runtime external', () => {
  185. const graph = bootGraph()
  186. const { target } = injectedFacade(graph)
  187. target.load({
  188. id: MODULES_ID,
  189. factory: (require) => {
  190. require('react')
  191. return modulesClient
  192. },
  193. })
  194. expect(() => target.create({ boot: graph, staticModules: {} }))
  195. .toThrow(`${MODULES_ID}/client.js requested external "react"`)
  196. })
  197. it.each([
  198. null,
  199. { ...modulesClient, createClientModuleSystem: undefined },
  200. { ...modulesClient, apply: undefined },
  201. ])('rejects a bootstrap bundle without the complete module face', (exports) => {
  202. const graph = bootGraph()
  203. const { target } = injectedFacade(graph)
  204. target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
  205. expect(() => target.create({ boot: graph, staticModules: {} }))
  206. .toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
  207. })
  208. })
  209. describe('client bundle activation', () => {
  210. it('allows sibling dsh roles', () => {
  211. const currentName = '@fixture/current-client-field'
  212. const clientPath = writePackage(currentName, {
  213. dsh: {
  214. bundle: { patch: './cordis.patch.yml' },
  215. client: { platform: 'web' },
  216. profile: { bundles: [] },
  217. },
  218. })
  219. mkdirSync(dirname(clientPath), { recursive: true })
  220. writeFileSync(clientPath, 'module.exports = {}\n')
  221. expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
  222. })
  223. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  224. const firstName = '@fixture/missing-first'
  225. const secondName = '@fixture/missing-second'
  226. const firstPath = writePackage(firstName)
  227. const secondPath = writePackage(secondName)
  228. expect(() => construct([firstName, secondName])).toThrow([
  229. 'client-modules: 2 client packages failed to compose:',
  230. ' client bundles not found; run `pnpm run build` before launch:',
  231. ` - package: ${firstName}`,
  232. ` path: ${firstPath}`,
  233. ` - package: ${secondName}`,
  234. ` path: ${secondPath}`,
  235. ].join('\n'))
  236. })
  237. it('does not report other bundle read failures as missing builds', () => {
  238. const packageName = '@fixture/unreadable-client'
  239. const clientPath = writePackage(packageName)
  240. mkdirSync(clientPath, { recursive: true })
  241. let thrown: unknown
  242. try {
  243. construct([packageName])
  244. } catch (error) {
  245. thrown = error
  246. }
  247. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  248. expect(String(thrown)).toContain(' other failures:')
  249. expect(String(thrown)).toContain('EISDIR')
  250. expect(String(thrown)).not.toContain('pnpm run build')
  251. })
  252. it('falls back to a generated-file map when an authored map is malformed', async () => {
  253. const packageName = '@fixture/malformed-source-map'
  254. const clientPath = writePackage(packageName)
  255. mkdirSync(dirname(clientPath), { recursive: true })
  256. writeFileSync(clientPath, 'module.exports = {}\n')
  257. writeFileSync(`${clientPath}.map`, '{')
  258. const torn = constructWithRoute([packageName])
  259. const tornRow = torn.service.graph().entries[0]!
  260. expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
  261. .toContain(`sourceMappingURL=${mapUrl(tornRow.url)}`)
  262. const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
  263. expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
  264. sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
  265. })
  266. writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
  267. expect(() => construct([packageName])).not.toThrow()
  268. })
  269. it('maps packed combo sections back to each generated client bundle', async () => {
  270. const names = ['@fixture/generated-first', '@fixture/generated-second']
  271. for (const [index, packageName] of names.entries()) {
  272. const clientPath = writePackage(packageName)
  273. mkdirSync(dirname(clientPath), { recursive: true })
  274. writeFileSync(
  275. clientPath,
  276. `window.generation = ${String(index)}\n//# sourceURL=packages/client/generated-${String(index)}/lib/client.js`,
  277. )
  278. }
  279. const { service, route } = constructWithRoute(names)
  280. const batch = service.graph().batches[0]!
  281. const script = (await routeRequest(route, batch.url)).body.toString('utf8')
  282. expect(script).not.toContain('//# sourceURL=')
  283. expect(script).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
  284. const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
  285. sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
  286. }
  287. expect(payload.sections.map(section => section.map)).toEqual([
  288. {
  289. version: 3,
  290. names: [],
  291. mappings: 'AAAA',
  292. sources: ['/packages/client/generated-0/lib/client.js'],
  293. sourcesContent: ['window.generation = 0\n'],
  294. },
  295. {
  296. version: 3,
  297. names: [],
  298. mappings: 'AAAA',
  299. sources: ['/packages/client/generated-1/lib/client.js'],
  300. sourcesContent: ['window.generation = 1\n'],
  301. },
  302. ])
  303. const consumer = new SourceMap(payload as unknown as ConstructorParameters<typeof SourceMap>[0])
  304. expect(consumer.findEntry(0, 0)).toMatchObject({
  305. originalSource: '/packages/client/generated-0/lib/client.js',
  306. })
  307. expect(consumer.findEntry(2, 0)).toMatchObject({
  308. originalSource: '/packages/client/generated-1/lib/client.js',
  309. })
  310. })
  311. it('retains one prior immutable batch generation across rebuild recomposition', async () => {
  312. const packageName = '@fixture/batch-rebuild-race'
  313. const clientPath = writePackage(packageName)
  314. mkdirSync(dirname(clientPath), { recursive: true })
  315. writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
  316. const { service, route } = constructWithRoute([packageName])
  317. const first = service.graph().batches[0]!.url
  318. const firstSize = service.artifactBaseline(packageName)!.size
  319. writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
  320. service.rebuilt(packageName)
  321. const second = service.graph().batches[0]!.url
  322. expect(second).not.toBe(first)
  323. expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
  324. expect((await routeRequest(route, first)).status).toBe(200)
  325. expect((await routeRequest(route, second)).status).toBe(200)
  326. writeFileSync(clientPath, 'module.exports = { generation: 3 }\n')
  327. service.rebuilt(packageName)
  328. const third = service.graph().batches[0]!.url
  329. expect((await routeRequest(route, first)).status).toBe(404)
  330. expect((await routeRequest(route, second)).status).toBe(200)
  331. expect((await routeRequest(route, third)).status).toBe(200)
  332. })
  333. it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
  334. const firstName = '@fixture/startup-revision-first'
  335. const secondName = '@fixture/startup-revision-second'
  336. writeBuiltPackage(firstName, {})
  337. writeBuiltPackage(secondName, {})
  338. const service = construct([firstName, secondName])
  339. const [first, second] = service.graph().entries
  340. const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
  341. const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
  342. expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
  343. expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
  344. const firstPath = service.clientPath(firstName)!
  345. const firstStat = statSync(firstPath)
  346. expect(service.artifactBaseline(firstName)).toEqual({
  347. path: firstPath,
  348. mtimeMs: firstStat.mtimeMs,
  349. size: firstStat.size,
  350. mapMtimeMs: null,
  351. mapSize: null,
  352. })
  353. expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
  354. })
  355. it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
  356. const packageNames = Array.from({ length: 48 }, (_, index) => (
  357. `@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
  358. ))
  359. const sourceMap = JSON.stringify({
  360. version: 3,
  361. names: [],
  362. mappings: 'AAAA',
  363. sources: ['src/index.ts'],
  364. })
  365. for (const packageName of packageNames) {
  366. const clientPath = writePackage(packageName)
  367. mkdirSync(dirname(clientPath), { recursive: true })
  368. writeFileSync(clientPath, 'module.exports = {}\n')
  369. writeFileSync(`${clientPath}.map`, sourceMap)
  370. }
  371. const { service, route } = constructWithRoute(packageNames)
  372. const batches = service.graph().batches.filter(batch => batch.phase === 'application')
  373. expect(batches.length).toBeGreaterThan(1)
  374. expect(batches.flatMap(batch => batch.entries)).toEqual(packageNames)
  375. for (const batch of batches) {
  376. expect(Buffer.byteLength(batch.url)).toBeLessThanOrEqual(3 * 1024)
  377. expect(Buffer.byteLength(mapUrl(batch.url))).toBeLessThanOrEqual(3 * 1024)
  378. expect((await routeRequest(route, batch.url)).status).toBe(200)
  379. expect((await routeRequest(route, mapUrl(batch.url))).status).toBe(200)
  380. }
  381. for (let index = 0; index < batches.length - 1; index += 1) {
  382. const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
  383. expect(Buffer.byteLength(mapUrl(comboUrl(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
  384. }
  385. })
  386. it('serves the source map beside a registered client bundle', async () => {
  387. const packageName = '@fixture/source-map'
  388. const clientPath = writePackage(packageName)
  389. mkdirSync(dirname(clientPath), { recursive: true })
  390. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  391. const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n'
  392. writeFileSync(`${clientPath}.map`, map)
  393. const { service, route } = constructWithRoute([packageName])
  394. const row = service.graph().entries[0]!
  395. const singleScript = await routeRequest(route, row.url)
  396. expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapUrl(row.url)}`)
  397. const singleMap = await routeRequest(route, mapUrl(row.url))
  398. expect(singleMap.status).toBe(200)
  399. expect(singleMap.headers).toEqual({
  400. 'content-type': 'application/json; charset=utf-8',
  401. 'cache-control': 'public, max-age=31536000, immutable',
  402. })
  403. expect(JSON.parse(singleMap.body.toString('utf8'))).toMatchObject({
  404. version: 3,
  405. file: 'client.js',
  406. sections: [{
  407. offset: { line: 0, column: 0 },
  408. map: {
  409. ...(JSON.parse(map) as Record<string, unknown>),
  410. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  411. },
  412. }],
  413. })
  414. const batch = service.graph().batches[0]!
  415. expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
  416. const batchScript = await routeRequest(route, batch.url)
  417. expect(batchScript.status).toBe(200)
  418. expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
  419. expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
  420. expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
  421. expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
  422. const batchMap = await routeRequest(route, mapUrl(batch.url))
  423. const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
  424. const parsedPluginMap = JSON.parse(map) as Record<string, unknown>
  425. expect(parsedBatchMap).toMatchObject({
  426. version: 3,
  427. file: 'client.js',
  428. sections: [{
  429. offset: { line: 0, column: 0 },
  430. map: {
  431. ...parsedPluginMap,
  432. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  433. },
  434. }],
  435. })
  436. expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
  437. writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
  438. const nextRev = service.rebuilt(packageName)
  439. expect(nextRev).not.toBe(row.rev)
  440. const nextRow = service.graph().entries[0]!
  441. expect(nextRow.rev).toBe(nextRev)
  442. const nextMap = await routeRequest(route, mapUrl(nextRow.url))
  443. expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({
  444. sections: [{ map: { sources: ['/plugins/@fixture/source-map/src/changed.tsx'] } }],
  445. })
  446. })
  447. it('applies sourceRoot before relocating absolute-looking section sources', async () => {
  448. const packageName = '@fixture/source-root'
  449. const clientPath = writePackage(packageName)
  450. mkdirSync(dirname(clientPath), { recursive: true })
  451. writeFileSync(clientPath, 'module.exports = {}\n')
  452. writeFileSync(`${clientPath}.map`, JSON.stringify({
  453. version: 3,
  454. names: [],
  455. mappings: 'AAAA',
  456. sourceRoot: '../root',
  457. sources: ['/absolute.ts'],
  458. }))
  459. const { service, route } = constructWithRoute([packageName])
  460. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  461. const map = JSON.parse(response.body.toString('utf8')) as {
  462. sections: { map: { sourceRoot?: string; sources: string[] } }[]
  463. }
  464. expect(map.sections[0]?.map).toMatchObject({
  465. sources: ['/plugins/@fixture/root/absolute.ts'],
  466. })
  467. expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot')
  468. })
  469. it('maps a non-zero second batch section through a standard source-map consumer', async () => {
  470. const firstName = '@fixture/offset-first'
  471. const secondName = '@fixture/offset-second'
  472. const firstPath = writePackage(firstName)
  473. const secondPath = writePackage(secondName)
  474. for (const [path, source] of [
  475. [firstPath, '../../../packages/demo/first.ts'],
  476. [secondPath, '../../../packages/demo/second.ts'],
  477. ] as const) {
  478. mkdirSync(dirname(path), { recursive: true })
  479. writeFileSync(path, 'window.first = true\nwindow.second = true\n')
  480. writeFileSync(`${path}.map`, JSON.stringify({
  481. version: 3,
  482. names: [],
  483. mappings: 'AAAA',
  484. sources: [source],
  485. sourcesContent: ['export {}\n'],
  486. }))
  487. }
  488. const { service, route } = constructWithRoute([firstName, secondName])
  489. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  490. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  491. const sections = (payload as unknown as {
  492. sections: { offset: { line: number; column: number } }[]
  493. }).sections
  494. expect(sections.map(section => section.offset)).toEqual([
  495. { line: 0, column: 0 },
  496. { line: 3, column: 0 },
  497. ])
  498. const consumer = new SourceMap(payload)
  499. expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
  500. expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
  501. })
  502. it('combines a generated-file fallback with a later authored map', async () => {
  503. const unmappedName = '@fixture/unmapped-first'
  504. const mappedName = '@fixture/mapped-second'
  505. const unmappedPath = writePackage(unmappedName)
  506. const mappedPath = writePackage(mappedName)
  507. mkdirSync(dirname(unmappedPath), { recursive: true })
  508. mkdirSync(dirname(mappedPath), { recursive: true })
  509. writeFileSync(unmappedPath, 'window.unmapped = true\n')
  510. writeFileSync(mappedPath, 'window.mapped = true\n')
  511. writeFileSync(`${mappedPath}.map`, JSON.stringify({
  512. version: 3,
  513. names: [],
  514. mappings: 'AAAA',
  515. sources: ['../../../packages/demo/mapped.ts'],
  516. sourcesContent: ['export {}\n'],
  517. }))
  518. const { service, route } = constructWithRoute([unmappedName, mappedName])
  519. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  520. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  521. const consumer = new SourceMap(payload)
  522. expect(consumer.findEntry(0, 0)).toMatchObject({
  523. originalSource: `/plugins/${unmappedName}/client.js`,
  524. })
  525. expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
  526. })
  527. })
  528. describe('shared module declarations', () => {
  529. it('accepts external requests and carries them onto the graph row', () => {
  530. const packageName = '@fixture/shared-declared'
  531. writeBuiltPackage(packageName, { external: ['react'] })
  532. expect(construct([packageName]).graph().entries).toEqual([{
  533. id: packageName,
  534. url: expect.stringContaining(`/plugins/??${packageName}/client.js&rev=`) as unknown as string,
  535. rev: expect.any(String) as unknown as string,
  536. external: ['react'],
  537. }])
  538. })
  539. it('omits external when the package declares no requests', () => {
  540. const packageName = '@fixture/shared-absent'
  541. writeBuiltPackage(packageName, {})
  542. const [row] = construct([packageName]).graph().entries
  543. expect(row).not.toHaveProperty('external')
  544. })
  545. it('rejects a non-array external', () => {
  546. const packageName = '@fixture/external-not-array'
  547. writeBuiltPackage(packageName, { external: 'react' })
  548. expect(() => construct([packageName]))
  549. .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
  550. })
  551. })
  552. describe('module graph order', () => {
  553. const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
  554. ({ id, url: comboUrl([id], '0'), rev: '0', ...fields })
  555. const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
  556. it('places every requested package row before its consumers along a chain', () => {
  557. expect(ids(orderByModuleGraph([
  558. entry('ui', { external: ['slots'] }),
  559. entry('slots', { external: ['render'] }),
  560. entry('render'),
  561. ]))).toEqual(['render', 'slots', 'ui'])
  562. })
  563. it('places a shared package row before both arms of a diamond', () => {
  564. expect(ids(orderByModuleGraph([
  565. entry('app', { external: ['left', 'right'] }),
  566. entry('left', { external: ['vendor'] }),
  567. entry('right', { external: ['vendor'] }),
  568. entry('vendor'),
  569. ]))).toEqual(['vendor', 'left', 'right', 'app'])
  570. })
  571. it('resolves a /client request onto the requested package row', () => {
  572. expect(ids(orderByModuleGraph([
  573. entry('ui', { external: ['runtime/client'] }),
  574. entry('runtime'),
  575. ]))).toEqual(['runtime', 'ui'])
  576. })
  577. it('leaves a request no row answers to the static assembly channel', () => {
  578. expect(ids(orderByModuleGraph([
  579. entry('consumer', { external: ['@deepseek-ai/cordis'] }),
  580. entry('other'),
  581. ]))).toEqual(['consumer', 'other'])
  582. })
  583. it('rejects a cycle and names the packages on it', () => {
  584. expect(() => orderByModuleGraph([
  585. entry('a', { external: ['b'] }),
  586. entry('b', { external: ['a'] }),
  587. ])).toThrow('client-modules: module graph cycle a -> b -> a')
  588. })
  589. it('rejects a row requesting its own package name', () => {
  590. expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
  591. .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
  592. })
  593. it('composes the served graph in module-graph order', () => {
  594. const consumerName = '@fixture/order-consumer'
  595. const dependencyName = '@fixture/order-dependency'
  596. writeBuiltPackage(consumerName, { external: [dependencyName] })
  597. writeBuiltPackage(dependencyName, {})
  598. expect(ids(construct([consumerName, dependencyName]).graph().entries))
  599. .toEqual([dependencyName, consumerName])
  600. })
  601. it('fails activation loud when scanned packages form a module cycle', () => {
  602. writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
  603. writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
  604. expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
  605. .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
  606. })
  607. })