http-bridge.host.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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. requestBodyMode: () => 'buffered',
  26. fetch: () => { throw new Error('a rejected request must never reach the handler') },
  27. }, 1000)
  28. // The socket must not stay parked draining a body the client can trickle
  29. // at will after the rejection — same discipline as the chunked overrun.
  30. expect(status).toBe(413)
  31. expect(headers).toMatchObject({ connection: 'close' })
  32. expect(destroyed).toHaveLength(1)
  33. })
  34. it('aborts a pending native picker request when the browser disconnects', async () => {
  35. const body = JSON.stringify({
  36. type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} },
  37. })
  38. const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
  39. Object.assign(request, {
  40. url: '/api/directoryPicker/pick',
  41. method: 'POST',
  42. headers: { 'content-type': 'application/json' },
  43. })
  44. const response = Object.assign(new EventEmitter(), {
  45. writableEnded: false,
  46. writeHead() { return this },
  47. write() { return true },
  48. end() { this.writableEnded = true; return this },
  49. }) as unknown as ServerResponse
  50. let resolveStarted!: () => void
  51. const started = new Promise<void>((resolve) => { resolveStarted = resolve })
  52. let carrierSignal: AbortSignal | undefined
  53. const pending = bridge(request, response, {
  54. requestBodyMode: () => 'buffered',
  55. fetch: async (input) => {
  56. const fetchRequest = input
  57. carrierSignal = fetchRequest.signal
  58. resolveStarted()
  59. if (!fetchRequest.signal.aborted) {
  60. await new Promise<void>((resolve) => {
  61. fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true })
  62. })
  63. }
  64. return Response.json({ aborted: fetchRequest.signal.aborted })
  65. },
  66. }, Number.MAX_SAFE_INTEGER)
  67. await started
  68. response.emit('close')
  69. await pending
  70. expect(carrierSignal?.aborted).toBe(true)
  71. })
  72. it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => {
  73. const request = new Readable({ read() {} }) as unknown as IncomingMessage
  74. Object.assign(request, {
  75. url: '/api/session/uploadFileBinary?sessionId=s1',
  76. method: 'POST',
  77. headers: {
  78. 'content-type': 'application/octet-stream',
  79. 'content-length': String(Math.ceil(2.19 * 1024 ** 3)),
  80. },
  81. })
  82. let status: number | undefined
  83. const responseBytes: Uint8Array[] = []
  84. const response = Object.assign(new EventEmitter(), {
  85. writableEnded: false,
  86. writeHead(code: number) { status = code; return this },
  87. write(chunk: Uint8Array) { responseBytes.push(chunk); return true },
  88. end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
  89. }) as unknown as ServerResponse
  90. let resolveStarted!: () => void
  91. const started = new Promise<void>((resolve) => { resolveStarted = resolve })
  92. const received: Uint8Array[] = []
  93. const pending = bridge(request, response, {
  94. requestBodyMode: () => 'streaming',
  95. fetch: async (input) => {
  96. resolveStarted()
  97. if (input.body === null) throw new Error('streaming request lost its body')
  98. for await (const chunk of input.body) received.push(chunk)
  99. return new Response('stored')
  100. },
  101. }, 1)
  102. await started
  103. expect(received).toEqual([])
  104. request.push(Buffer.from([1, 2]))
  105. request.push(Buffer.from([3, 4]))
  106. request.push(null)
  107. await pending
  108. expect(status).toBe(200)
  109. expect(received).toEqual([Uint8Array.of(1, 2), Uint8Array.of(3, 4)])
  110. expect(Buffer.concat(responseBytes).toString()).toBe('stored')
  111. })
  112. it('closes an unread streaming request after returning an early validation response', async () => {
  113. const destroyed: true[] = []
  114. const request = new Readable({ read() {} }) as unknown as IncomingMessage
  115. Object.assign(request, {
  116. url: '/api/session/uploadFileBinary',
  117. method: 'POST',
  118. headers: { 'content-type': 'application/json' },
  119. destroy: () => { destroyed.push(true) },
  120. })
  121. let status: number | undefined
  122. let headers: unknown
  123. const response = Object.assign(new EventEmitter(), {
  124. writableEnded: false,
  125. writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
  126. write() { return true },
  127. end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
  128. }) as unknown as ServerResponse
  129. await bridge(request, response, {
  130. requestBodyMode: () => 'streaming',
  131. fetch: () => Promise.resolve(new Response(null, { status: 415 })),
  132. }, 1)
  133. expect(status).toBe(415)
  134. expect(headers).toMatchObject({ connection: 'close' })
  135. expect(destroyed).toEqual([true])
  136. })
  137. })