client-source.host.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /** Host-side controller for the isolated Client test fixture. */
  2. import { Worker } from 'node:worker_threads'
  3. import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts'
  4. import type { CordisRuntimeTree } from '../../src/shared/cordis/model.ts'
  5. import type { InspectorJsonValue } from '../../src/shared/json.ts'
  6. /** Optional source artifact exposed by the Client fixture. */
  7. interface ClientFixtureSourceCatalog {
  8. readonly sourceText: string
  9. readonly sourceMap: string
  10. readonly sourceUrl: string
  11. readonly sourceMapUrl: string
  12. }
  13. /** Options for one isolated Client fixture. */
  14. export interface ClientFixtureOptions {
  15. readonly label?: string
  16. readonly sourceCatalog?: ClientFixtureSourceCatalog
  17. }
  18. interface FixtureResponse {
  19. readonly type: 'response'
  20. readonly id: number
  21. readonly ok: boolean
  22. readonly value?: unknown
  23. readonly error?: string
  24. }
  25. /** A Client producer running outside the Host test realm. */
  26. export class InspectorClientFixture {
  27. private readonly worker: Worker
  28. private readonly pending = new Map<number, PromiseWithResolvers<unknown>>()
  29. private nextId = 0
  30. private closed = false
  31. readonly fiberUid: number
  32. private constructor(worker: Worker, fiberUid: number) {
  33. this.worker = worker
  34. this.fiberUid = fiberUid
  35. worker.on('message', (message: unknown) => { this.receive(message) })
  36. worker.on('error', (error) => { this.fail(error) })
  37. worker.on('exit', (code) => {
  38. if (!this.closed && code !== 0) this.fail(new Error(`Inspector Client fixture exited with code ${String(code)}`))
  39. })
  40. }
  41. /** Start one Client fixture and wait for its Cordis tree to be published. */
  42. static async start(
  43. bootstrap: InspectorClientBootstrap,
  44. options: ClientFixtureOptions = {},
  45. ): Promise<InspectorClientFixture> {
  46. const ready = Promise.withResolvers<number>()
  47. const entry = new URL('./client-source.client.ts', import.meta.url)
  48. const tsxApi = import.meta.resolve('tsx/esm/api')
  49. const source = `import { register } from ${JSON.stringify(tsxApi)}\nregister()\nawait import(${JSON.stringify(entry.href)})`
  50. const worker = new Worker(new URL(`data:text/javascript,${encodeURIComponent(source)}`), {
  51. execArgv: [],
  52. workerData: {
  53. bootstrap,
  54. label: options.label ?? 'Test Client',
  55. ...(options.sourceCatalog === undefined ? {} : { sourceCatalog: options.sourceCatalog }),
  56. },
  57. })
  58. const onMessage = (message: unknown): void => {
  59. if (!isRecord(message) || message.type !== 'ready' || typeof message.fiberUid !== 'number') return
  60. ready.resolve(message.fiberUid)
  61. }
  62. worker.on('message', onMessage)
  63. worker.once('error', ready.reject)
  64. const fiberUid = await ready.promise
  65. worker.off('message', onMessage)
  66. return new InspectorClientFixture(worker, fiberUid)
  67. }
  68. /** Publish one observation from the Client realm. */
  69. async publish(topic: string, value: InspectorJsonValue): Promise<void> {
  70. await this.request({ op: 'publish', topic, value })
  71. }
  72. /** Set one JSON-compatible global used by Client Runtime evaluation. */
  73. async setGlobal(name: string, value: InspectorJsonValue): Promise<void> {
  74. await this.request({ op: 'set-global', name, value })
  75. }
  76. /** Emit one Console event carrying a caller-provided value. */
  77. async log(value: InspectorJsonValue, marker: string): Promise<void> {
  78. await this.request({ op: 'log-value', value, marker })
  79. }
  80. /** Emit one Console event carrying the fixture's Context and Fiber. */
  81. async logCordis(marker: string): Promise<void> {
  82. await this.request({ op: 'log-cordis', marker })
  83. }
  84. /** Read the consumer-neutral Cordis tree through the Client service. */
  85. async getCordisTree(): Promise<CordisRuntimeTree> {
  86. return await this.request({ op: 'get-tree' }) as CordisRuntimeTree
  87. }
  88. /**
  89. * Pause or resume ingest reads without blocking the fixture MessagePort.
  90. * @param paused - Whether incoming WebSocket frames must wait.
  91. */
  92. async setIngestPaused(paused: boolean): Promise<void> {
  93. await this.request({ op: 'set-ingest-paused', paused })
  94. }
  95. /** Break the active ingest socket while preserving the Client source. */
  96. async disconnect(): Promise<void> {
  97. await this.request({ op: 'disconnect' })
  98. }
  99. /** Trigger a Cordis observation without changing the runtime tree. */
  100. async refreshTree(): Promise<void> {
  101. await this.request({ op: 'refresh-tree' })
  102. }
  103. /** Add one Fiber to the inspected Client runtime. */
  104. async addFiber(): Promise<number> {
  105. return await this.request({ op: 'add-fiber' }) as number
  106. }
  107. /** Remove the Fiber most recently added by {@link addFiber}. */
  108. async removeFiber(): Promise<void> {
  109. await this.request({ op: 'remove-fiber' })
  110. }
  111. /** Dispose the Client source and its Cordis context. */
  112. async close(): Promise<void> {
  113. if (this.closed) return
  114. await this.request({ op: 'close' })
  115. this.closed = true
  116. await this.worker.terminate()
  117. }
  118. private async request(fields: Record<string, unknown>): Promise<unknown> {
  119. if (this.closed) throw new Error('Inspector Client fixture is closed')
  120. const id = ++this.nextId
  121. const result = Promise.withResolvers<unknown>()
  122. this.pending.set(id, result)
  123. this.worker.postMessage({ id, ...fields })
  124. return await result.promise
  125. }
  126. private receive(message: unknown): void {
  127. if (!isRecord(message) || message.type !== 'response' || typeof message.id !== 'number') return
  128. const response = message as unknown as FixtureResponse
  129. const pending = this.pending.get(response.id)
  130. if (pending === undefined) return
  131. this.pending.delete(response.id)
  132. if (response.ok) pending.resolve(response.value)
  133. else pending.reject(new Error(response.error ?? 'Inspector Client fixture request failed'))
  134. }
  135. private fail(error: Error): void {
  136. for (const pending of this.pending.values()) pending.reject(error)
  137. this.pending.clear()
  138. }
  139. }
  140. function isRecord(value: unknown): value is Record<string, unknown> {
  141. return typeof value === 'object' && value !== null && !Array.isArray(value)
  142. }