node-half.client.spec.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964
  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, FiberState, type Fiber } from '@deepseek-ai/cordis'
  10. import { afterEach, describe, expect, it, vi } 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. const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
  24. afterEach(async () => {
  25. await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
  26. await ready
  27. await ctx.fiber.dispose()
  28. }))
  29. if (root !== undefined) rmSync(root, { recursive: true, force: true })
  30. root = undefined
  31. })
  32. it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
  33. const ctx = new Context()
  34. contexts.push({ ctx })
  35. ctx.provide('loader', { entries: () => [] })
  36. const routes = new Set<WebRoute>()
  37. const mountServer = () => ctx.plugin((serverCtx) => {
  38. serverCtx.provide('webServer', {
  39. register: (route: WebRoute) => {
  40. routes.add(route)
  41. return () => { routes.delete(route) }
  42. },
  43. } as WebServer)
  44. })
  45. let server = serverFirst ? await mountServer() : undefined
  46. const modules = await ctx.plugin(ClientModuleRegistry)
  47. const service = ctx.get('clientModules')!
  48. expect(service.graph().entries).toEqual([])
  49. expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
  50. if (!serverFirst) {
  51. expect(routes.size).toBe(0)
  52. server = await mountServer()
  53. }
  54. await expect.poll(() => routes.size).toBe(1)
  55. await server!.dispose()
  56. await expect.poll(() => routes.size).toBe(0)
  57. expect(modules.state).toBe(FiberState.ACTIVE)
  58. expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
  59. await mountServer()
  60. await expect.poll(() => routes.size).toBe(1)
  61. await modules.dispose()
  62. expect(routes.size).toBe(0)
  63. })
  64. /** Create a resolvable package whose client export points at the returned path. */
  65. function writePackage(
  66. packageName: string,
  67. metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
  68. ): string {
  69. root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
  70. const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
  71. const clientPath = join(pkgRoot, 'lib', 'client.js')
  72. mkdirSync(pkgRoot, { recursive: true })
  73. writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
  74. name: packageName,
  75. exports: {
  76. './client': './lib/client.js',
  77. './package.json': './package.json',
  78. },
  79. ...metadata,
  80. }))
  81. return clientPath
  82. }
  83. /** Create a built package with the supplied client declaration. */
  84. function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
  85. const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
  86. mkdirSync(dirname(clientPath), { recursive: true })
  87. writeFileSync(clientPath, 'module.exports = {}\n')
  88. }
  89. /** Construct the node-half service and capture its plugin-bundle route. */
  90. function constructWithRoute(
  91. packageNames: string[],
  92. options: {
  93. contextBaseUrl?: string
  94. entryBaseUrl?: string
  95. internal?: NonNullable<Context['loader']['internal']>
  96. } = {},
  97. ): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
  98. const ctx = new Context()
  99. const owned: typeof contexts[number] = { ctx }
  100. contexts.push(owned)
  101. ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
  102. ctx.provide('loader', {
  103. internal: options.internal,
  104. *entries() {
  105. for (const packageName of packageNames) {
  106. yield {
  107. options: { name: packageName },
  108. fiber: {},
  109. disabled: false,
  110. parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } },
  111. }
  112. }
  113. },
  114. })
  115. const route = Promise.withResolvers<WebRoute>()
  116. const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
  117. port: 0,
  118. register: (candidate) => {
  119. if (candidate.path === '/plugins') route.resolve(candidate)
  120. return () => {}
  121. },
  122. tapIndex: () => () => {},
  123. }
  124. ctx.provide('webServer', webServer as WebServer)
  125. const service = new ClientModuleRegistry(ctx)
  126. owned.ready = route.promise
  127. return { context: ctx, service, route: route.promise }
  128. }
  129. /** Construct the node-half service over the enabled fixture entries. */
  130. function construct(packageNames: string[]): ClientModuleRegistry {
  131. return constructWithRoute(packageNames).service
  132. }
  133. /** Invoke the registered plugin route and capture status, headers, and bytes. */
  134. async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
  135. status: number
  136. headers: Record<string, string> | undefined
  137. body: Buffer
  138. }> {
  139. let status = 0
  140. let headers: Record<string, string> | undefined
  141. let body = Buffer.alloc(0)
  142. const response = {
  143. writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
  144. status = nextStatus
  145. headers = nextHeaders
  146. return response
  147. },
  148. end(chunk?: Uint8Array) {
  149. body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
  150. return response
  151. },
  152. } as unknown as ServerResponse
  153. await (await route).handler({ method, url } as IncomingMessage, response)
  154. return { status, headers, body }
  155. }
  156. /** Execute the exact first inline script emitted by the Host boot rows. */
  157. function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
  158. const html = renderIndexInjections(
  159. '<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
  160. bootInjections(graph),
  161. )
  162. const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
  163. if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
  164. const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
  165. runInNewContext(source, { window })
  166. if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
  167. return { html, target: window.__ModuleLoader__ }
  168. }
  169. const bootGraph = (): WebBootGraph => ({
  170. rev: 'graph',
  171. entries: [
  172. { id: MODULES_ID, url: comboUrl([MODULES_ID], 'm'), rev: 'm' },
  173. { id: UI_RENDERER_ID, url: comboUrl([UI_RENDERER_ID], 'r'), rev: 'r' },
  174. ],
  175. batches: [
  176. {
  177. phase: 'bootstrap',
  178. url: BOOTSTRAP_URL,
  179. rev: 'boot',
  180. entries: [MODULES_ID],
  181. },
  182. {
  183. phase: 'application',
  184. url: APPLICATION_URL,
  185. rev: 'app',
  186. entries: [UI_RENDERER_ID],
  187. },
  188. ],
  189. })
  190. describe('HTML bootstrap facade', () => {
  191. it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
  192. const graph = bootGraph()
  193. const { html, target } = injectedFacade(graph)
  194. const facadeAt = html.indexOf('window.__ModuleLoader__=')
  195. const applicationAt = html.indexOf(
  196. `<link rel="preload" as="script" href="${APPLICATION_URL.replaceAll('&', '&amp;')}">`,
  197. )
  198. const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL.replaceAll('&', '&amp;')}"></script>`)
  199. const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
  200. const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
  201. expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
  202. facadeAt, applicationAt, bootstrapAt, graphAt, entryAt,
  203. ])].sort((a, b) => a - b))
  204. target.load({ id: MODULES_ID, factory: () => modulesClient })
  205. const system = target.create({
  206. boot: graph,
  207. staticModules: {},
  208. loadBundle: async (url) => {
  209. expect(url).toBe(APPLICATION_URL)
  210. target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
  211. },
  212. })
  213. expect(target.mode).toBe('live')
  214. expect(target.pendingQueue).toEqual([])
  215. expect(system.manifest.rev).toBe('graph')
  216. expect(await system.import(MODULES_ID)).toBe(modulesClient)
  217. expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' })
  218. expect(() => target.create({ boot: graph, staticModules: {} }))
  219. .toThrow('create called after module-system boot')
  220. })
  221. it('preloads every application combo', () => {
  222. const graph = bootGraph()
  223. const secondId = '@fixture/second-application-combo'
  224. const secondUrl = comboUrl([secondId], 'app-2')
  225. graph.entries.push({ id: secondId, url: comboUrl([secondId], 'row-2'), rev: 'row-2' })
  226. graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
  227. expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
  228. .toEqual([APPLICATION_URL, secondUrl])
  229. })
  230. it('rejects a page that did not preload the modules bundle', () => {
  231. const graph = bootGraph()
  232. const { target } = injectedFacade(graph)
  233. expect(() => target.create({ boot: graph, staticModules: {} }))
  234. .toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
  235. })
  236. it('rejects a bootstrap bundle with a runtime external', () => {
  237. const graph = bootGraph()
  238. const { target } = injectedFacade(graph)
  239. target.load({
  240. id: MODULES_ID,
  241. factory: (require) => {
  242. require('react')
  243. return modulesClient
  244. },
  245. })
  246. expect(() => target.create({ boot: graph, staticModules: {} }))
  247. .toThrow(`${MODULES_ID}/client.js requested external "react"`)
  248. })
  249. it.each([
  250. null,
  251. { ...modulesClient, createClientModuleSystem: undefined },
  252. { ...modulesClient, apply: undefined },
  253. ])('rejects a bootstrap bundle without the complete module face', (exports) => {
  254. const graph = bootGraph()
  255. const { target } = injectedFacade(graph)
  256. target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
  257. expect(() => target.create({ boot: graph, staticModules: {} }))
  258. .toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
  259. })
  260. })
  261. describe('client bundle activation', () => {
  262. it.each(['v1', 'v2'] as const)(
  263. 'resolves %s package metadata from the owning entry tree',
  264. (version) => {
  265. const packageName = `@fixture/entry-base-${version}`
  266. const clientPath = writePackage(packageName)
  267. const hostPath = join(dirname(clientPath), 'index.js')
  268. mkdirSync(dirname(hostPath), { recursive: true })
  269. writeFileSync(hostPath, 'export default {}\n')
  270. writeFileSync(clientPath, 'module.exports = {}\n')
  271. const contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/'
  272. const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
  273. const calls: unknown[][] = []
  274. const resolveSync = (...args: unknown[]) => {
  275. calls.push(args)
  276. return { format: 'module' as const, url: pathToFileURL(hostPath).href }
  277. }
  278. const internal = { version, resolveSync }
  279. const { service } = constructWithRoute([packageName], {
  280. contextBaseUrl,
  281. entryBaseUrl,
  282. internal: internal as NonNullable<Context['loader']['internal']>,
  283. })
  284. expect(calls).toEqual(version === 'v2'
  285. ? [[entryBaseUrl, { specifier: packageName, attributes: {} }]]
  286. : [[packageName, entryBaseUrl, {}]])
  287. expect(service.clientPath(packageName)).toBe(clientPath)
  288. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  289. },
  290. )
  291. it('derives the browser module id from a file entry owning manifest', () => {
  292. const packageName = '@fixture/file-entry'
  293. const clientPath = writePackage(packageName)
  294. const hostPath = join(dirname(clientPath), 'index.js')
  295. mkdirSync(dirname(hostPath), { recursive: true })
  296. writeFileSync(hostPath, 'export default {}\n')
  297. writeFileSync(clientPath, 'module.exports = {}\n')
  298. const service = construct([pathToFileURL(hostPath).href])
  299. expect(service.clientPath(packageName)).toBe(clientPath)
  300. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  301. })
  302. it.each(['relative', 'absolute'] as const)(
  303. 'finds the owning manifest through the %s-path fallback without Node loader internals',
  304. (kind) => {
  305. const packageName = `@fixture/${kind}-fallback-entry`
  306. const clientPath = writePackage(packageName)
  307. const packageRoot = dirname(dirname(clientPath))
  308. const hostPath = join(packageRoot, 'index.js')
  309. mkdirSync(dirname(clientPath), { recursive: true })
  310. writeFileSync(hostPath, 'export default {}\n')
  311. writeFileSync(clientPath, 'module.exports = {}\n')
  312. const loaderName = kind === 'relative' ? './index.js' : hostPath
  313. const { service } = constructWithRoute([loaderName], {
  314. entryBaseUrl: pathToFileURL(packageRoot).href + '/',
  315. })
  316. expect(service.clientPath(packageName)).toBe(clientPath)
  317. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  318. },
  319. )
  320. it.each(['v1', 'v2', 'worker'] as const)(
  321. 'derives a file entry package id through the %s Loader resolver',
  322. (version) => {
  323. const packageName = `@fixture/file-entry-${version}`
  324. const clientPath = writePackage(packageName)
  325. const hostPath = join(dirname(clientPath), 'index.js')
  326. mkdirSync(dirname(hostPath), { recursive: true })
  327. writeFileSync(hostPath, 'export default {}\n')
  328. writeFileSync(clientPath, 'module.exports = {}\n')
  329. const loaderName = pathToFileURL(hostPath).href
  330. const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
  331. const calls: unknown[][] = []
  332. const resolveSync = (...args: unknown[]) => {
  333. calls.push(args)
  334. return { format: 'module' as const, url: loaderName }
  335. }
  336. const internal = { version, resolveSync }
  337. const { service } = constructWithRoute([loaderName], {
  338. entryBaseUrl,
  339. internal: internal as NonNullable<Context['loader']['internal']>,
  340. })
  341. expect(calls).toEqual(version === 'v2'
  342. ? [[entryBaseUrl, { specifier: loaderName, attributes: {} }]]
  343. : [[loaderName, entryBaseUrl, {}]])
  344. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  345. },
  346. )
  347. it('rejects distinct active Loader sources for one browser package', () => {
  348. const packageName = '@fixture/duplicate-source'
  349. const clientPath = writePackage(packageName)
  350. const hostPath = join(dirname(clientPath), 'index.js')
  351. mkdirSync(dirname(hostPath), { recursive: true })
  352. writeFileSync(hostPath, 'export default {}\n')
  353. writeFileSync(clientPath, 'module.exports = {}\n')
  354. const alias = './duplicate-source.js'
  355. const internal = {
  356. version: 'v2' as const,
  357. resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
  358. }
  359. expect(() => constructWithRoute([packageName, alias], {
  360. internal: internal as unknown as NonNullable<Context['loader']['internal']>,
  361. })).toThrow(
  362. `client-modules: package ${packageName} resolves from multiple active Loader sources:`,
  363. )
  364. })
  365. it('promotes the remaining Loader source after the selected alias unloads', async () => {
  366. const packageName = '@fixture/duplicate-source-recovery'
  367. const clientPath = writePackage(packageName)
  368. const hostPath = join(dirname(clientPath), 'index.js')
  369. mkdirSync(dirname(hostPath), { recursive: true })
  370. writeFileSync(hostPath, 'export default {}\n')
  371. writeFileSync(clientPath, 'module.exports = {}\n')
  372. const alias = './duplicate-source-recovery.js'
  373. const entries = [packageName]
  374. const internal = {
  375. version: 'v2' as const,
  376. resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
  377. }
  378. const { context, service } = constructWithRoute(entries, {
  379. internal: internal as unknown as NonNullable<Context['loader']['internal']>,
  380. })
  381. const firstRevision = service.graph().entries[0]!.rev
  382. const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined)
  383. entries.push(alias)
  384. emitLoaderEntryChange(context, alias)
  385. await Promise.resolve()
  386. expect(warning).toHaveBeenCalledWith(expect.objectContaining({
  387. message: expect.stringContaining(`package ${packageName} resolves from multiple active Loader sources`) as string,
  388. }))
  389. expect(service.graph().entries[0]!.rev).toBe(firstRevision)
  390. entries.splice(entries.indexOf(packageName), 1)
  391. emitLoaderEntryChange(context, packageName)
  392. await Promise.resolve()
  393. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  394. expect(service.graph().entries[0]!.rev).not.toBe(firstRevision)
  395. expect(service.clientPath(packageName)).toBe(clientPath)
  396. })
  397. it('uses owning-tree package resolution for an import-only Worker module loader', () => {
  398. const packageName = '@fixture/worker-loader'
  399. writeBuiltPackage(packageName, {})
  400. const internal = {
  401. version: 'worker',
  402. import: async () => ({}),
  403. } as unknown as NonNullable<Context['loader']['internal']>
  404. const { service } = constructWithRoute([packageName], { internal })
  405. expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
  406. })
  407. it('allows sibling dsh roles', () => {
  408. const currentName = '@fixture/current-client-field'
  409. const clientPath = writePackage(currentName, {
  410. dsh: {
  411. bundle: { patch: './cordis.patch.yml' },
  412. client: { platform: 'web' },
  413. profile: { bundles: [] },
  414. },
  415. })
  416. mkdirSync(dirname(clientPath), { recursive: true })
  417. writeFileSync(clientPath, 'module.exports = {}\n')
  418. expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
  419. })
  420. it('groups missing bundles under one source-build instruction with a package/path list', () => {
  421. const firstName = '@fixture/missing-first'
  422. const secondName = '@fixture/missing-second'
  423. const firstPath = writePackage(firstName)
  424. const secondPath = writePackage(secondName)
  425. expect(() => construct([firstName, secondName])).toThrow([
  426. 'client-modules: 2 client packages failed to compose:',
  427. ' client bundles not found; run `pnpm run build` before launch:',
  428. ` - package: ${firstName}`,
  429. ` path: ${firstPath}`,
  430. ` - package: ${secondName}`,
  431. ` path: ${secondPath}`,
  432. ].join('\n'))
  433. })
  434. it('does not report other bundle read failures as missing builds', () => {
  435. const packageName = '@fixture/unreadable-client'
  436. const clientPath = writePackage(packageName)
  437. mkdirSync(clientPath, { recursive: true })
  438. let thrown: unknown
  439. try {
  440. construct([packageName])
  441. } catch (error) {
  442. thrown = error
  443. }
  444. expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
  445. expect(String(thrown)).toContain(' other failures:')
  446. expect(String(thrown)).toContain('EISDIR')
  447. expect(String(thrown)).not.toContain('pnpm run build')
  448. })
  449. it('falls back to a generated-file map when an authored map is malformed', async () => {
  450. const packageName = '@fixture/malformed-source-map'
  451. const clientPath = writePackage(packageName)
  452. mkdirSync(dirname(clientPath), { recursive: true })
  453. writeFileSync(clientPath, 'module.exports = {}\n')
  454. writeFileSync(`${clientPath}.map`, '{')
  455. const torn = constructWithRoute([packageName])
  456. const tornRow = torn.service.graph().entries[0]!
  457. expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
  458. .toContain(`sourceMappingURL=${mapUrl(tornRow.url)}`)
  459. const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
  460. expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
  461. sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
  462. })
  463. writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
  464. expect(() => construct([packageName])).not.toThrow()
  465. writeFileSync(`${clientPath}.map`, JSON.stringify({
  466. version: 3,
  467. names: [],
  468. mappings: 'AAAA',
  469. sourceRoot: 'http://[',
  470. sources: ['src/index.ts'],
  471. }))
  472. const invalidUrl = constructWithRoute([packageName])
  473. const invalidMapUrl = mapUrl(invalidUrl.service.graph().batches[0]!.url)
  474. expect(JSON.parse((await routeRequest(invalidUrl.route, invalidMapUrl)).body.toString('utf8'))).toMatchObject({
  475. sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
  476. })
  477. })
  478. it('reads a source map only on its first map GET', async () => {
  479. const packageName = '@fixture/lazy-source-map'
  480. const clientPath = writePackage(packageName)
  481. mkdirSync(dirname(clientPath), { recursive: true })
  482. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  483. writeFileSync(`${clientPath}.map`, '{')
  484. const { service, route } = constructWithRoute([packageName])
  485. const batch = service.graph().batches[0]!
  486. const sourceMapUrl = mapUrl(batch.url)
  487. const script = await routeRequest(route, batch.url)
  488. expect(script.status).toBe(200)
  489. expect((await routeRequest(route, sourceMapUrl, 'HEAD')).body).toHaveLength(0)
  490. writeFileSync(`${clientPath}.map`, JSON.stringify({
  491. version: 3,
  492. names: [],
  493. mappings: 'AAAA',
  494. sources: ['src/first.ts'],
  495. }))
  496. const first = await routeRequest(route, sourceMapUrl)
  497. expect(JSON.parse(first.body.toString('utf8'))).toMatchObject({
  498. sections: [{ map: { sources: [`/plugins/${packageName}/src/first.ts`] } }],
  499. })
  500. writeFileSync(`${clientPath}.map`, JSON.stringify({
  501. version: 3,
  502. names: [],
  503. mappings: 'AAAA',
  504. sources: ['src/second.ts'],
  505. }))
  506. expect((await routeRequest(route, sourceMapUrl)).body).toEqual(first.body)
  507. expect((await routeRequest(route, batch.url)).body).toEqual(script.body)
  508. })
  509. it('retains a materialized resource when an unrelated row recomposes the graph', async () => {
  510. const stablePackage = '@fixture/stable-source-map'
  511. const rebuiltPackage = '@fixture/rebuilt-neighbor'
  512. const stablePath = writePackage(stablePackage)
  513. const rebuiltPath = writePackage(rebuiltPackage)
  514. for (const clientPath of [stablePath, rebuiltPath]) {
  515. mkdirSync(dirname(clientPath), { recursive: true })
  516. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  517. writeFileSync(`${clientPath}.map`, JSON.stringify({
  518. version: 3,
  519. names: [],
  520. mappings: 'AAAA',
  521. sources: ['src/first.ts'],
  522. }))
  523. }
  524. const { service, route } = constructWithRoute([stablePackage, rebuiltPackage])
  525. const stableUrl = service.graph().entries.find(entry => entry.id === stablePackage)!.url
  526. const stableMapUrl = mapUrl(stableUrl)
  527. const first = await routeRequest(route, stableMapUrl)
  528. writeFileSync(`${stablePath}.map`, JSON.stringify({
  529. version: 3,
  530. names: [],
  531. mappings: 'AAAA',
  532. sources: ['src/second.ts'],
  533. }))
  534. writeFileSync(rebuiltPath, 'module.exports = { rebuilt: true }\n')
  535. service.rebuilt(rebuiltPackage)
  536. expect((await routeRequest(route, stableMapUrl)).body).toEqual(first.body)
  537. })
  538. it('maps packed combo sections back to each generated client bundle', async () => {
  539. const names = ['@fixture/generated-first', '@fixture/generated-second']
  540. for (const [index, packageName] of names.entries()) {
  541. const clientPath = writePackage(packageName)
  542. mkdirSync(dirname(clientPath), { recursive: true })
  543. writeFileSync(
  544. clientPath,
  545. `window.generation = ${String(index)}\n//# sourceURL=packages/client/generated-${String(index)}/lib/client.js`,
  546. )
  547. }
  548. const { service, route } = constructWithRoute(names)
  549. const batch = service.graph().batches[0]!
  550. const script = (await routeRequest(route, batch.url)).body.toString('utf8')
  551. expect(script).not.toContain('//# sourceURL=')
  552. expect(script).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
  553. const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
  554. sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
  555. }
  556. expect(payload.sections.map(section => section.map)).toEqual([
  557. {
  558. version: 3,
  559. names: [],
  560. mappings: 'AAAA',
  561. sources: ['/packages/client/generated-0/lib/client.js'],
  562. sourcesContent: ['window.generation = 0\n'],
  563. },
  564. {
  565. version: 3,
  566. names: [],
  567. mappings: 'AAAA',
  568. sources: ['/packages/client/generated-1/lib/client.js'],
  569. sourcesContent: ['window.generation = 1\n'],
  570. },
  571. ])
  572. const consumer = new SourceMap(payload as unknown as ConstructorParameters<typeof SourceMap>[0])
  573. expect(consumer.findEntry(0, 0)).toMatchObject({
  574. originalSource: '/packages/client/generated-0/lib/client.js',
  575. })
  576. expect(consumer.findEntry(2, 0)).toMatchObject({
  577. originalSource: '/packages/client/generated-1/lib/client.js',
  578. })
  579. })
  580. it('retains one prior immutable batch generation across rebuild recomposition', async () => {
  581. const packageName = '@fixture/batch-rebuild-race'
  582. const clientPath = writePackage(packageName)
  583. mkdirSync(dirname(clientPath), { recursive: true })
  584. writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
  585. const { service, route } = constructWithRoute([packageName])
  586. const first = service.graph().batches[0]!.url
  587. const firstSize = service.artifactBaseline(packageName)!.size
  588. writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
  589. service.rebuilt(packageName)
  590. const second = service.graph().batches[0]!.url
  591. expect(second).not.toBe(first)
  592. expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
  593. expect((await routeRequest(route, first)).status).toBe(200)
  594. expect((await routeRequest(route, second)).status).toBe(200)
  595. writeFileSync(clientPath, 'module.exports = { generation: 3 }\n')
  596. service.rebuilt(packageName)
  597. const third = service.graph().batches[0]!.url
  598. expect((await routeRequest(route, first)).status).toBe(404)
  599. expect((await routeRequest(route, second)).status).toBe(200)
  600. expect((await routeRequest(route, third)).status).toBe(200)
  601. })
  602. it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
  603. const firstName = '@fixture/startup-revision-first'
  604. const secondName = '@fixture/startup-revision-second'
  605. writeBuiltPackage(firstName, {})
  606. writeBuiltPackage(secondName, {})
  607. const service = construct([firstName, secondName])
  608. const [first, second] = service.graph().entries
  609. const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
  610. const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
  611. expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
  612. expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
  613. const firstPath = service.clientPath(firstName)!
  614. const firstStat = statSync(firstPath)
  615. expect(service.artifactBaseline(firstName)).toEqual({
  616. path: firstPath,
  617. mtimeMs: firstStat.mtimeMs,
  618. size: firstStat.size,
  619. })
  620. expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
  621. })
  622. it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
  623. const packageNames = Array.from({ length: 48 }, (_, index) => (
  624. `@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
  625. ))
  626. const sourceMap = JSON.stringify({
  627. version: 3,
  628. names: [],
  629. mappings: 'AAAA',
  630. sources: ['src/index.ts'],
  631. })
  632. for (const packageName of packageNames) {
  633. const clientPath = writePackage(packageName)
  634. mkdirSync(dirname(clientPath), { recursive: true })
  635. writeFileSync(clientPath, 'module.exports = {}\n')
  636. writeFileSync(`${clientPath}.map`, sourceMap)
  637. }
  638. const { service, route } = constructWithRoute(packageNames)
  639. const batches = service.graph().batches.filter(batch => batch.phase === 'application')
  640. expect(batches.length).toBeGreaterThan(1)
  641. expect(batches.flatMap(batch => batch.entries)).toEqual(packageNames)
  642. for (const batch of batches) {
  643. expect(Buffer.byteLength(batch.url)).toBeLessThanOrEqual(3 * 1024)
  644. expect(Buffer.byteLength(mapUrl(batch.url))).toBeLessThanOrEqual(3 * 1024)
  645. expect((await routeRequest(route, batch.url)).status).toBe(200)
  646. expect((await routeRequest(route, mapUrl(batch.url))).status).toBe(200)
  647. }
  648. for (let index = 0; index < batches.length - 1; index += 1) {
  649. const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
  650. expect(Buffer.byteLength(mapUrl(comboUrl(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
  651. }
  652. })
  653. it('serves the source map beside a registered client bundle', async () => {
  654. const packageName = '@fixture/source-map'
  655. const clientPath = writePackage(packageName)
  656. mkdirSync(dirname(clientPath), { recursive: true })
  657. writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
  658. const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n'
  659. writeFileSync(`${clientPath}.map`, map)
  660. const { service, route } = constructWithRoute([packageName])
  661. const row = service.graph().entries[0]!
  662. const singleScript = await routeRequest(route, row.url)
  663. expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapUrl(row.url)}`)
  664. const singleMap = await routeRequest(route, mapUrl(row.url))
  665. expect(singleMap.status).toBe(200)
  666. expect(singleMap.headers).toEqual({
  667. 'content-type': 'application/json; charset=utf-8',
  668. 'cache-control': 'public, max-age=31536000, immutable',
  669. })
  670. expect(JSON.parse(singleMap.body.toString('utf8'))).toMatchObject({
  671. version: 3,
  672. file: 'client.js',
  673. sections: [{
  674. offset: { line: 0, column: 0 },
  675. map: {
  676. ...(JSON.parse(map) as Record<string, unknown>),
  677. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  678. },
  679. }],
  680. })
  681. const batch = service.graph().batches[0]!
  682. expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
  683. const batchScript = await routeRequest(route, batch.url)
  684. expect(batchScript.status).toBe(200)
  685. expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
  686. expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`)
  687. const shellResponse = await service.fetchBundle(new Request(`dsh-app://app${batch.url}`))
  688. expect(shellResponse.status).toBe(200)
  689. expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
  690. expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8'))
  691. expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
  692. expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
  693. const batchMap = await routeRequest(route, mapUrl(batch.url))
  694. const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown
  695. const parsedPluginMap = JSON.parse(map) as Record<string, unknown>
  696. expect(parsedBatchMap).toMatchObject({
  697. version: 3,
  698. file: 'client.js',
  699. sections: [{
  700. offset: { line: 0, column: 0 },
  701. map: {
  702. ...parsedPluginMap,
  703. sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
  704. },
  705. }],
  706. })
  707. expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
  708. writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
  709. const nextRev = service.rebuilt(packageName)
  710. expect(nextRev).not.toBe(row.rev)
  711. const nextRow = service.graph().entries[0]!
  712. expect(nextRow.rev).toBe(nextRev)
  713. const nextMap = await routeRequest(route, mapUrl(nextRow.url))
  714. expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({
  715. sections: [{ map: { sources: ['/plugins/@fixture/source-map/src/changed.tsx'] } }],
  716. })
  717. })
  718. it('applies sourceRoot before relocating absolute-looking section sources', async () => {
  719. const packageName = '@fixture/source-root'
  720. const clientPath = writePackage(packageName)
  721. mkdirSync(dirname(clientPath), { recursive: true })
  722. writeFileSync(clientPath, 'module.exports = {}\n')
  723. writeFileSync(`${clientPath}.map`, JSON.stringify({
  724. version: 3,
  725. names: [],
  726. mappings: 'AAAA',
  727. sourceRoot: '../root',
  728. sources: ['/absolute.ts'],
  729. }))
  730. const { service, route } = constructWithRoute([packageName])
  731. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  732. const map = JSON.parse(response.body.toString('utf8')) as {
  733. sections: { map: { sourceRoot?: string; sources: string[] } }[]
  734. }
  735. expect(map.sections[0]?.map).toMatchObject({
  736. sources: ['/plugins/@fixture/root/absolute.ts'],
  737. })
  738. expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot')
  739. })
  740. it('maps a non-zero second batch section through a standard source-map consumer', async () => {
  741. const firstName = '@fixture/offset-first'
  742. const secondName = '@fixture/offset-second'
  743. const firstPath = writePackage(firstName)
  744. const secondPath = writePackage(secondName)
  745. for (const [path, source] of [
  746. [firstPath, '../../../packages/demo/first.ts'],
  747. [secondPath, '../../../packages/demo/second.ts'],
  748. ] as const) {
  749. mkdirSync(dirname(path), { recursive: true })
  750. writeFileSync(path, 'window.first = true\nwindow.second = true\n')
  751. writeFileSync(`${path}.map`, JSON.stringify({
  752. version: 3,
  753. names: [],
  754. mappings: 'AAAA',
  755. sources: [source],
  756. sourcesContent: ['export {}\n'],
  757. }))
  758. }
  759. const { service, route } = constructWithRoute([firstName, secondName])
  760. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  761. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  762. const sections = (payload as unknown as {
  763. sections: { offset: { line: number; column: number } }[]
  764. }).sections
  765. expect(sections.map(section => section.offset)).toEqual([
  766. { line: 0, column: 0 },
  767. { line: 3, column: 0 },
  768. ])
  769. const consumer = new SourceMap(payload)
  770. expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
  771. expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
  772. })
  773. it('combines a generated-file fallback with a later authored map', async () => {
  774. const unmappedName = '@fixture/unmapped-first'
  775. const mappedName = '@fixture/mapped-second'
  776. const unmappedPath = writePackage(unmappedName)
  777. const mappedPath = writePackage(mappedName)
  778. mkdirSync(dirname(unmappedPath), { recursive: true })
  779. mkdirSync(dirname(mappedPath), { recursive: true })
  780. writeFileSync(unmappedPath, 'window.unmapped = true\n')
  781. writeFileSync(mappedPath, 'window.mapped = true\n')
  782. writeFileSync(`${mappedPath}.map`, JSON.stringify({
  783. version: 3,
  784. names: [],
  785. mappings: 'AAAA',
  786. sources: ['../../../packages/demo/mapped.ts'],
  787. sourcesContent: ['export {}\n'],
  788. }))
  789. const { service, route } = constructWithRoute([unmappedName, mappedName])
  790. const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
  791. const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
  792. const consumer = new SourceMap(payload)
  793. expect(consumer.findEntry(0, 0)).toMatchObject({
  794. originalSource: `/plugins/${unmappedName}/client.js`,
  795. })
  796. expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
  797. })
  798. })
  799. function emitLoaderEntryChange(context: Context, name: string): void {
  800. context.emit('internal/plugin', {
  801. entry: { options: { name } },
  802. } as unknown as Fiber)
  803. }
  804. describe('shared module declarations', () => {
  805. it('accepts external requests and carries them onto the graph row', () => {
  806. const packageName = '@fixture/shared-declared'
  807. writeBuiltPackage(packageName, { external: ['react'] })
  808. expect(construct([packageName]).graph().entries).toEqual([{
  809. id: packageName,
  810. url: expect.stringContaining(`/plugins/??${packageName}/client.js&rev=`) as unknown as string,
  811. rev: expect.any(String) as unknown as string,
  812. external: ['react'],
  813. }])
  814. })
  815. it('omits external when the package declares no requests', () => {
  816. const packageName = '@fixture/shared-absent'
  817. writeBuiltPackage(packageName, {})
  818. const [row] = construct([packageName]).graph().entries
  819. expect(row).not.toHaveProperty('external')
  820. })
  821. it('rejects a non-array external', () => {
  822. const packageName = '@fixture/external-not-array'
  823. writeBuiltPackage(packageName, { external: 'react' })
  824. expect(() => construct([packageName]))
  825. .toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
  826. })
  827. })
  828. describe('module graph order', () => {
  829. const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
  830. ({ id, url: comboUrl([id], '0'), rev: '0', ...fields })
  831. const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
  832. it('places every requested package row before its consumers along a chain', () => {
  833. expect(ids(orderByModuleGraph([
  834. entry('ui', { external: ['slots'] }),
  835. entry('slots', { external: ['render'] }),
  836. entry('render'),
  837. ]))).toEqual(['render', 'slots', 'ui'])
  838. })
  839. it('places a shared package row before both arms of a diamond', () => {
  840. expect(ids(orderByModuleGraph([
  841. entry('app', { external: ['left', 'right'] }),
  842. entry('left', { external: ['vendor'] }),
  843. entry('right', { external: ['vendor'] }),
  844. entry('vendor'),
  845. ]))).toEqual(['vendor', 'left', 'right', 'app'])
  846. })
  847. it('resolves a /client request onto the requested package row', () => {
  848. expect(ids(orderByModuleGraph([
  849. entry('ui', { external: ['runtime/client'] }),
  850. entry('runtime'),
  851. ]))).toEqual(['runtime', 'ui'])
  852. })
  853. it('leaves a request no row answers to the static assembly channel', () => {
  854. expect(ids(orderByModuleGraph([
  855. entry('consumer', { external: ['@deepseek-ai/cordis'] }),
  856. entry('other'),
  857. ]))).toEqual(['consumer', 'other'])
  858. })
  859. it('rejects a cycle and names the packages on it', () => {
  860. expect(() => orderByModuleGraph([
  861. entry('a', { external: ['b'] }),
  862. entry('b', { external: ['a'] }),
  863. ])).toThrow('client-modules: module graph cycle a -> b -> a')
  864. })
  865. it('rejects a row requesting its own package name', () => {
  866. expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
  867. .toThrow('client-modules: "solo" requests module "solo" that it answers itself')
  868. })
  869. it('composes the served graph in module-graph order', () => {
  870. const consumerName = '@fixture/order-consumer'
  871. const dependencyName = '@fixture/order-dependency'
  872. writeBuiltPackage(consumerName, { external: [dependencyName] })
  873. writeBuiltPackage(dependencyName, {})
  874. expect(ids(construct([consumerName, dependencyName]).graph().entries))
  875. .toEqual([dependencyName, consumerName])
  876. })
  877. it('fails activation loud when scanned packages form a module cycle', () => {
  878. writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
  879. writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
  880. expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
  881. .toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
  882. })
  883. })