diff --git a/packages/apollo-server/src/__tests__/stoppable.test.ts b/packages/apollo-server/src/__tests__/stoppable.test.ts new file mode 100644 index 00000000000..6213a009f93 --- /dev/null +++ b/packages/apollo-server/src/__tests__/stoppable.test.ts @@ -0,0 +1,278 @@ +// This file is adapted from the stoppable npm package: +// https://github.com/hunterloftis/stoppable +// +// We've ported it to TypeScript and made some further changes. +// Here's the license of the original code: +// +// The MIT License (MIT) +// +// Copyright (c) 2017 Hunter Loftis +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +const http = require('http') +const https = require('https') +const a = require('awaiting') +const request = require('requisition') +const assert = require('chai').assert +const fs = require('fs') +const stoppable = require('..') +const child = require('child_process') +const path = require('path') + +const PORT = 8000 + +const schemes = { + http: { + agent: (opts = {}) => new http.Agent(opts), + server: handler => http.createServer(handler || ((req, res) => res.end('hello'))) + }, + https: { + agent: (opts = {}) => https.Agent(Object.assign({rejectUnauthorized: false}, opts)), + server: handler => https.createServer({ + key: fs.readFileSync('test/fixture.key'), + cert: fs.readFileSync('test/fixture.cert') + }, handler || ((req, res) => res.end('hello'))) + } +} + +Object.keys(schemes).forEach(schemeName => { + const scheme = schemes[schemeName] + + describe(`${schemeName}.Server`, function () { + describe('.close()', () => { + let server + + beforeEach(function () { + server = scheme.server() + }) + + describe('without keep-alive connections', () => { + let closed = 0 + it('stops accepting new connections', async () => { + server.on('close', () => closed++) + server.listen(PORT) + await a.event(server, 'listening') + const res1 = + await request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent()) + const text1 = await res1.text() + assert.equal(text1, 'hello') + server.close() + const err = await a.failure( + request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent())) + assert.match(err.message, /ECONNREFUSED/) + }) + + it('closes', () => { + assert.equal(closed, 1) + }) + }) + + describe('with keep-alive connections', () => { + let closed = 0 + + it('stops accepting new connections', async () => { + server.on('close', () => closed++) + server.listen(PORT) + await a.event(server, 'listening') + const res1 = await request(`${schemeName}://localhost:${PORT}`) + .agent(scheme.agent({keepAlive: true})) + const text1 = await res1.text() + assert.equal(text1, 'hello') + server.close() + const err = + await a.failure(request(`${schemeName}://localhost:${PORT}`) + .agent(scheme.agent({keepAlive: true}))) + assert.match(err.message, /ECONNREFUSED/) + }) + + it("doesn't close", () => { + assert.equal(closed, 0) + }) + }) + }) + + describe('.stop()', function () { + describe('without keep-alive connections', function () { + let closed = 0 + let gracefully = false + let server + + beforeEach(function () { + server = stoppable(scheme.server()) + }) + + it('stops accepting new connections', async () => { + server.on('close', () => closed++) + server.listen(PORT) + await a.event(server, 'listening') + const res1 = + await request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent()) + const text1 = await res1.text() + assert.equal(text1, 'hello') + server.stop((e, g) => { + gracefully = g + }) + const err = await a.failure( + request(`${schemeName}://localhost:${PORT}`).agent(scheme.agent())) + assert.match(err.message, /ECONNREFUSED/) + }) + + it('closes', () => { + assert.equal(closed, 1) + }) + + it('gracefully', () => { + assert.isOk(gracefully) + }) + }) + + describe('with keep-alive connections', () => { + let closed = 0 + let gracefully = false + let server + + beforeEach(function () { + server = stoppable(scheme.server()) + }) + + it('stops accepting new connections', async () => { + server.on('close', () => closed++) + server.listen(PORT) + await a.event(server, 'listening') + const res1 = await request(`${schemeName}://localhost:${PORT}`) + .agent(scheme.agent({keepAlive: true})) + const text1 = await res1.text() + assert.equal(text1, 'hello') + server.stop((e, g) => { + gracefully = g + }) + const err = await a.failure(request(`${schemeName}://localhost:${PORT}`) + .agent(scheme.agent({ keepAlive: true }))) + assert.match(err.message, /ECONNREFUSED/) + }) + + it('closes', () => { assert.equal(closed, 1) }) + + it('gracefully', () => { + assert.isOk(gracefully) + }) + + it('empties all sockets once closed', + () => { assert.equal(server._pendingSockets.size, 0) }) + + it('registers the "close" callback', (done) => { + server.listen(PORT) + server.stop(done) + }) + }) + }) + + describe('with a 0.5s grace period', () => { + let gracefully = true + let server + + beforeEach(function () { + server = stoppable(scheme.server((req, res) => { + res.writeHead(200) + res.write('hi') + }), 500) + }) + + it('kills connections after 0.5s', async () => { + server.listen(PORT) + await a.event(server, 'listening') + await Promise.all([ + request(`${schemeName}://localhost:${PORT}`) + .agent(scheme.agent({keepAlive: true})), + request(`${schemeName}://localhost:${PORT}`) + .agent(scheme.agent({keepAlive: true})) + ]) + const start = Date.now() + server.stop((e, g) => { + gracefully = g + }) + await a.event(server, 'close') + assert.closeTo(Date.now() - start, 500, 50) + }) + + it('gracefully', () => { + assert.isNotOk(gracefully) + }) + + it('empties all sockets', () => { + assert.equal(server._pendingSockets.size, 0) + }) + }) + + describe('with requests in-flight', () => { + let server + let gracefully = false + + beforeEach(function () { + server = stoppable(scheme.server((req, res) => { + const delay = parseInt(req.url.slice(1), 10) + res.writeHead(200) + res.write('hello') + setTimeout(() => res.end('world'), delay) + })) + }) + + it('closes their sockets once they finish', async () => { + server.listen(PORT) + await a.event(server, 'listening') + const start = Date.now() + const res = await Promise.all([ + request(`${schemeName}://localhost:${PORT}/250`) + .agent(scheme.agent({keepAlive: true})), + request(`${schemeName}://localhost:${PORT}/500`) + .agent(scheme.agent({keepAlive: true})) + ]) + server.stop((e, g) => { + gracefully = g + }) + const bodies = await Promise.all(res.map(r => r.text())) + await a.event(server, 'close') + assert.equal(bodies[0], 'helloworld') + assert.closeTo(Date.now() - start, 500, 100) + }) + it('gracefully', () => { + assert.isOk(gracefully) + }) + + describe('with in-flights finishing before grace period ends', function () { + if (schemeName !== 'http') { + return + } + + it('exits immediately', async () => { + const file = path.join(__dirname, 'server.js') + const server = child.spawn('node', [file, '500']) + await a.event(server.stdout, 'data') + const start = Date.now() + const res = await request(`${schemeName}://localhost:${PORT}/250`) + .agent(scheme.agent({keepAlive: true})) + const body = await res.text() + assert.equal(body, 'helloworld') + assert.closeTo(Date.now() - start, 250, 100) + }) + }) + }) + }) +}) diff --git a/packages/apollo-server/src/__tests__/stoppable/fixture.cert b/packages/apollo-server/src/__tests__/stoppable/fixture.cert new file mode 100644 index 00000000000..9fca0516c55 --- /dev/null +++ b/packages/apollo-server/src/__tests__/stoppable/fixture.cert @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIJALpsaWTYcddKMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNzA1MTkxOTAwMzZaFw0yNzA1MTcxOTAw +MzZaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAOix4xb3jnIdWwqfT+vtoXadLuQJE21HN19Y2falkJuB +k/ME0TKMeMjmQQZCO7G0K+5fMnQsEtQjcjY3XUXViliGfkAQplUD3Z9/xwsTSGN2 +ahBXE2W/GV+xJ6uX9KbJx2pMOXCuux6cKylYhmr8cTs2f9E6QpPji4LqtHv/9cAE +QKRmv2rSAP1Q+1Ne2WYNbgHBuI35vuQsvZTN5QsozsferP9Qqtx8kpnBaLTgFZYD +ZaEreYwFFYAQNfq2jOGEAAxStiXUpn3rT9T8KeOvLfWOifqYzDOTzL0t2py9bnvl +x2fl8aJHc3NiU+4qlq3DuDEitiUoOkirGhFL7JFH4K0CAwEAAaNQME4wHQYDVR0O +BBYEFAI/PRTwA3VKpSQAwXg2JDmDGVXxMB8GA1UdIwQYMBaAFAI/PRTwA3VKpSQA +wXg2JDmDGVXxMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAHfYOl+n +qB0Oyq/2jXE33fR5+QsaDrciXH0BWCjIVM8ADLPjYPAD3StTIceg3wfCw2n9xuku +pObukGbApSqvGBSAIsvbbNAwcFp31DCqGQdfVMJMG1TCp3J7y0WslYMqU7DM1jYt +ybqF9ICuGdPZ0KVsv0/ZmbSs98/fSyjUYoHfD+GngVrguuU/v0XUi4hjVqFyMZQZ +AxGNq4QIlKxdo55L45vCMzGiajT7BE0EnChvFpOGXF5/pk072RESI7uxJBiAssWP +uCk0xHxLtacOQK3seFFw0d7t3769gVDNi732eTMhoFQj+loSgmnRwDKL7QPhZ8tj +pRRUGV4sPR+ucpo= +-----END CERTIFICATE----- diff --git a/packages/apollo-server/src/__tests__/stoppable/fixture.key b/packages/apollo-server/src/__tests__/stoppable/fixture.key new file mode 100644 index 00000000000..55824c5ac7c --- /dev/null +++ b/packages/apollo-server/src/__tests__/stoppable/fixture.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA6LHjFveOch1bCp9P6+2hdp0u5AkTbUc3X1jZ9qWQm4GT8wTR +Mox4yOZBBkI7sbQr7l8ydCwS1CNyNjddRdWKWIZ+QBCmVQPdn3/HCxNIY3ZqEFcT +Zb8ZX7Enq5f0psnHakw5cK67HpwrKViGavxxOzZ/0TpCk+OLguq0e//1wARApGa/ +atIA/VD7U17ZZg1uAcG4jfm+5Cy9lM3lCyjOx96s/1Cq3HySmcFotOAVlgNloSt5 +jAUVgBA1+raM4YQADFK2JdSmfetP1Pwp468t9Y6J+pjMM5PMvS3anL1ue+XHZ+Xx +okdzc2JT7iqWrcO4MSK2JSg6SKsaEUvskUfgrQIDAQABAoIBAE8UJRipCL+/OjFh +8sc6+qRUxpq4euGoUikVCP3JRluSrbTo7i8/jcy4c2CtIZxCnqtjrsHMOJnfcfD6 +37fb2ig7jKw4/E3oAmkyA3LAGtmyZFkpPm5Vg0oB6nlmKr6D1EFLpjmlJ/I/IGvs +qcGyCMkWvFlec0HPEpprKOr7EYkvQscy99ny7JswG1P9PELwo7tIeb0BioPYnFmF +I0BPgI1lxDHKQTUPAao9rStiHsuPMCkw51qUgp/Z814ld4KDXCaWFQPy5riHDykH +wm9n2hkM6pq4d6eHuMVj7CuBdp141k2BAnZdysMHpE9y1315+didoEcox8+zOLeO +OC4XZAECgYEA/U98ld2YnVcSL9/Pr17SVG/q3iZaUueUkf+CEdj7LpvStedpky5W +dOM7ad0kBcPqIafgn/O3teYjVl8FM0oOtOheMHHMkYxbXuECA5hkk7emu3oIJcAO ++9Pb/uGdufWmAVyRueRam4tubiLxv46xeGUmscCnwG78bj+rq74ATjcCgYEA6ypd +qt/b43y4SHY4LDuuJk5jfC5MNXztIi3sOtuGoJNUlzC/hI/NNhEDhP3Pzo9c/i0k +aCzyjhRyiaFK2SHQ5SQdCFi44PM+MptwFjY1KPGv20m5omfBgJOoF+Ad13qrUQF/ +b7/C5j3PZkOZfwaYO+erLeaayWKRJi2AEoXb9jsCgYEAnxAHuo/A4qQnXnqbDpNr +Xew9Pqw0sbSLvbYFNjHbYKQmh2U+DVbeoV2DFHHxydEBN4sUaTyAUq+l5vmZ6WAK +phz38FG1VHwfcA+41QsftQZwo274qMPWZNnfXkjMY1ZWnKpFM8aqAtxmRrCYv2Ha +HTDfQGUqsZK/3ncK1LhltrcCgYBiJKk4wfpL42YpX6Ur2LBibj6Yud22SO/SXuYC +3lE+PJ6GBqM3GKilEs6sNxz98Nj3fzF9hJyp7SCsDbNmEPXUW5D+RcDKqNlhV3uc +2XywHMWuuAMQI0sfdQAnDrKFlj1fLkfYBGi7nDotTLMHz2HDRnkrS913hHpdO4oC +sPjOtwKBgQDDiG7Vagk4SgPXt7zE0aSiFIIjJLpM28mES+k6zOtKAyOcTMHcDrI7 +YmSN1kq3w2g7RS5eMUpszqbUGoR6VDAjbgGAakDOno/uZWfEMjiQiKvRDSY1nmlc +xSKubMZDf/OKUYTGasL1rqJJN7mxW2irptygc26NxMeAWZfgkmiPLg== +-----END RSA PRIVATE KEY----- diff --git a/packages/apollo-server/src/__tests__/stoppable/server.js b/packages/apollo-server/src/__tests__/stoppable/server.js new file mode 100644 index 00000000000..477e9139f42 --- /dev/null +++ b/packages/apollo-server/src/__tests__/stoppable/server.js @@ -0,0 +1,13 @@ +const http = require('http') +const stoppable = require('..') + +const grace = Number(process.argv[2] || Infinity) +const server = http.createServer((req, res) => { + const delay = parseInt(req.url.slice(1), 10) + res.writeHead(200) + res.write('hello') + setTimeout(() => res.end('world'), delay) + server.stop() +}) +stoppable(server, grace) +server.listen(8000, () => console.log('listening'))