http-bridge.host.spec.ts 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. import { EventEmitter } from 'node:events'
  2. import { Readable } from 'node:stream'
  3. import type { IncomingMessage, ServerResponse } from 'node:http'
  4. import { describe, expect, it } from 'vitest'
  5. import { bridge } from '../src/http-bridge.ts'
  6. describe('HTTP bridge abort', () => {
  7. it('destroys a declared-oversize request instead of draining it', async () => {
  8. const destroyed: true[] = []
  9. const request = Readable.from([]) as unknown as IncomingMessage
  10. Object.assign(request, {
  11. url: '/api/session.prompt',
  12. method: 'POST',
  13. headers: { 'content-type': 'application/json', 'content-length': '999999' },
  14. destroy: () => { destroyed.push(true) },
  15. })
  16. let status: number | undefined
  17. let headers: unknown
  18. const response = Object.assign(new EventEmitter(), {
  19. writableEnded: false,
  20. writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
  21. write() { return true },
  22. end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
  23. }) as unknown as ServerResponse
  24. await bridge(request, response, {
  25. fetch: () => { throw new Error('a rejected request must never reach the handler') },
  26. }, 1000)
  27. // The socket must not stay parked draining a body the client can trickle
  28. // at will after the rejection — same discipline as the chunked overrun.
  29. expect(status).toBe(413)
  30. expect(headers).toMatchObject({ connection: 'close' })
  31. expect(destroyed).toHaveLength(1)
  32. })
  33. it('aborts a pending native picker request when the browser disconnects', async () => {
  34. const body = JSON.stringify({
  35. type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
  36. })
  37. const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
  38. Object.assign(request, {
  39. url: '/api/host.pickDirectory',
  40. method: 'POST',
  41. headers: { 'content-type': 'application/json' },
  42. })
  43. const response = Object.assign(new EventEmitter(), {
  44. writableEnded: false,
  45. writeHead() { return this },
  46. write() { return true },
  47. end() { this.writableEnded = true; return this },
  48. }) as unknown as ServerResponse
  49. let resolveStarted!: () => void
  50. const started = new Promise<void>((resolve) => { resolveStarted = resolve })
  51. let carrierSignal: AbortSignal | undefined
  52. const pending = bridge(request, response, {
  53. fetch: async (input) => {
  54. const fetchRequest = input
  55. carrierSignal = fetchRequest.signal
  56. resolveStarted()
  57. if (!fetchRequest.signal.aborted) {
  58. await new Promise<void>((resolve) => {
  59. fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true })
  60. })
  61. }
  62. return Response.json({ aborted: fetchRequest.signal.aborted })
  63. },
  64. }, Number.MAX_SAFE_INTEGER)
  65. await started
  66. response.emit('close')
  67. await pending
  68. expect(carrierSignal?.aborted).toBe(true)
  69. })
  70. })