@ 261107.5
current
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test, after } = require('node:test')
const { once } = require('node:events')
const assert = require('node:assert/strict')
const { setImmediate: waitImmediate } = require('node:timers/promises')
const { Client } = require('..')
const { kConnect } = require('../lib/core/symbols')
const { createServer } = require('node:net')
const http = require('node:http')
const FakeTimers = require('@sinonjs/fake-timers')

test('keep-alive header', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeout=2s\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      const timeout = setTimeout(() => {
        t.fail()
      }, 4e3)
      client.on('disconnect', () => {
        t.ok(true, 'pass')
        clearTimeout(timeout)
      })
    }).resume()
  })
  await t.completed
})

test('keep-alive header 0', async (t) => {
  t = tspl(t, { plan: 2 })

  const clock = FakeTimers.install()
  after(() => clock.uninstall())

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeout=1s\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`, {
    keepAliveTimeoutThreshold: 500
  })
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      client.on('disconnect', () => {
        t.ok(true, 'pass')
      })
      clock.tick(600)
    }).resume()
  })
  await t.completed
})

test('keep-alive header 1', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeout=1s\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      const timeout = setTimeout(() => {
        t.fail()
      }, 0)
      client.on('disconnect', () => {
        t.ok(true, 'pass')
        clearTimeout(timeout)
      })
    }).resume()
  })
  await t.completed
})

test('HEAD keep-alive header reuses socket when connection header is fragmented', async (t) => {
  t = tspl(t, { plan: 4 })

  let connections = 0
  let requests = 0

  const server = createServer((socket) => {
    connections++

    let request = ''
    socket.on('data', (chunk) => {
      request += chunk.toString()

      while (request.includes('\r\n\r\n')) {
        const endOfHeaders = request.indexOf('\r\n\r\n') + 4
        request = request.slice(endOfHeaders)
        requests++

        socket.write('HTTP/1.1 200 OK\r\n')
        socket.write('Content-Length: 0\r\n')
        socket.write('Connection: keep-')
        socket.write('alive\r\n')
        socket.write('\r\n')

        if (requests === 2) {
          socket.end()
        }
      }
    })
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.destroy())

  const disconnect = once(client, 'disconnect')

  const first = await client.request({
    path: '/',
    method: 'HEAD',
    reset: false
  })
  t.strictEqual(first.statusCode, 200)
  await first.body.text()

  const second = await client.request({
    path: '/',
    method: 'HEAD',
    reset: false
  })
  t.strictEqual(second.statusCode, 200)
  await second.body.text()

  await disconnect
  t.strictEqual(connections, 1)
  t.strictEqual(requests, 2)

  await t.completed
})

test('keep-alive header no postfix', async (t) => {
  t = tspl(t, { plan: 2 })

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeout=2\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      const timeout = setTimeout(() => {
        t.fail()
      }, 4e3)
      client.on('disconnect', () => {
        t.ok(true, 'pass')
        clearTimeout(timeout)
      })
    }).resume()
  })
  await t.completed
})

test('keep-alive not timeout', async (t) => {
  t = tspl(t, { plan: 2 })

  const clock = FakeTimers.install({
    apis: ['setTimeout']
  })
  after(() => clock.uninstall())

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeoutasdasd=1s\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())

  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`, {
    keepAliveTimeout: 1e3
  })
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      const timeout = setTimeout(t.fail, 3e3)
      client.on('disconnect', () => {
        t.ok(true, 'pass')
        clearTimeout(timeout)
      })
      clock.tick(1000)
    }).resume()
  })

  await t.completed
})

test('keep-alive threshold', async (t) => {
  t = tspl(t, { plan: 2 })

  const clock = FakeTimers.install({
    apis: ['setTimeout']
  })
  after(() => clock.uninstall())

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeout=30s\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`, {
    keepAliveTimeout: 30e3,
    keepAliveTimeoutThreshold: 29e3
  })
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      const timeout = setTimeout(() => {
        t.fail()
      }, 5e3)
      client.on('disconnect', () => {
        t.ok(true, 'pass')
        clearTimeout(timeout)
      })
      clock.tick(1000)
    }).resume()
  })
  await t.completed
})

test('keep-alive max keepalive', async (t) => {
  t = tspl(t, { plan: 2 })

  const clock = FakeTimers.install({
    apis: ['setTimeout']
  })
  after(() => clock.uninstall())

  const server = createServer((socket) => {
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Keep-Alive: timeout=30s\r\n')
    socket.write('Connection: keep-alive\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`, {
    keepAliveTimeout: 30e3,
    keepAliveMaxTimeout: 1e3
  })
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      const timeout = setTimeout(() => {
        t.fail()
      }, 3e3)
      client.on('disconnect', () => {
        t.ok(true, 'pass')
        clearTimeout(timeout)
      })
      clock.tick(1000)
    }).resume()
  })
  await t.completed
})

test('connection close', async (t) => {
  t = tspl(t, { plan: 4 })

  let close = false
  const server = createServer((socket) => {
    if (close) {
      return
    }
    close = true
    socket.write('HTTP/1.1 200 OK\r\n')
    socket.write('Content-Length: 0\r\n')
    socket.write('Connection: close\r\n')
    socket.write('\r\n\r\n')
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`, {
    pipelining: 2
  })
  after(() => client.close())

  client[kConnect](() => {
    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      body.on('end', () => {
        const timeout = setTimeout(() => {
          t.fail()
        }, 3e3)
        client.once('disconnect', () => {
          close = false
          t.ok(true, 'pass')
          clearTimeout(timeout)
        })
      }).resume()
    })

    client.request({
      path: '/',
      method: 'GET'
    }, (err, { body }) => {
      t.ifError(err)
      body.on('end', () => {
        const timeout = setTimeout(() => {
          t.fail()
        }, 3e3)
        client.once('disconnect', () => {
          t.ok(true, 'pass')
          clearTimeout(timeout)
        })
      }).resume()
    })
  })
  await t.completed
})

test('Disable keep alive', async (t) => {
  t = tspl(t, { plan: 7 })

  const ports = []
  const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => {
    t.strictEqual(ports.includes(req.socket.remotePort), false)
    ports.push(req.socket.remotePort)
    t.strictEqual(req.headers.connection, 'close')
    res.writeHead(200, { connection: 'close' })
    res.end()
  })
  after(() => server.close())
  await once(server.listen(0), 'listening')

  const client = new Client(`http://localhost:${server.address().port}`, { pipelining: 0 })
  after(() => client.close())

  client.request({
    path: '/',
    method: 'GET'
  }, (err, { body }) => {
    t.ifError(err)
    body.on('end', () => {
      client.request({
        path: '/',
        method: 'GET'
      }, (err, { body }) => {
        t.ifError(err)
        body.on('end', () => {
          t.ok(true, 'pass')
        }).resume()
      })
    }).resume()
  })
  await t.completed
})

test('HEAD connection tokens preserve reuse and respect close', { timeout: 10000 }, async (t) => {
  const useMilo = ['1', 'true'].includes(process.env.UNDICI_USE_MILO)
  for (const [name, fields, expectedConnections] of [
    ['repeated fragmented keep-alive', ['x-token', 'keep-alive'], 1],
    ['keep-alive before another field', ['keep-alive', 'x-token'], 1],
    ['comma-separated tokens and whitespace', ['x-token, \tKEEP-ALIVE\t '], 1],
    ['close before keep-alive', ['close', 'keep-alive'], 2],
    ['close after keep-alive', ['keep-alive', 'close'], 2],
    ['close in a comma-separated list', ['keep-alive, CLOSE'], 2],
    ['a longer token does not match', ['x-keep-alive'], 2]
  ]) {
    await t.test(name, async (t) => {
      let connections = 0
      const server = createServer(socket => {
        connections++
        let pending = ''
        let responses = Promise.resolve()
        socket.on('data', chunk => {
          pending += chunk.toString()
          while (pending.includes('\r\n\r\n')) {
            pending = pending.slice(pending.indexOf('\r\n\r\n') + 4)
            responses = responses.then(async () => {
              const response = 'HTTP/1.1 200 OK\r\n' +
                (useMilo ? 'Content-Length: 0\r\n' : '') +
                fields.map(value => `Connection: ${value}\r\n`).join('') + '\r\n'
              if (useMilo) {
                socket.write(response)
                return
              }
              // Split the final field value so token parsing also covers fragments.
              const split = response.length - 7
              socket.write(response.slice(0, split))
              await waitImmediate()
              socket.write(response.slice(split))
            })
          }
        })
      })
      t.after(() => server.close())
      server.listen(0)
      await once(server, 'listening')
      const client = new Client(`http://localhost:${server.address().port}`, { useMilo })
      t.after(() => client.destroy())
      for (let i = 0; i < 2; i++) {
        const response = await client.request({ path: '/', method: 'HEAD', reset: false })
        await response.body.dump()
      }
      // A framed HTTP/1.1 response stays persistent without any keep-alive token.
      const expected = ['1', 'true'].includes(process.env.UNDICI_USE_MILO) &&
        name === 'a longer token does not match'
        ? 1
        : expectedConnections
      assert.equal(connections, expected)
    })
  }
})