Skip to content

Commit

Permalink
share test server / tunnel
Browse files Browse the repository at this point in the history
because cloudflared is a bit unstable sometimes so it's better to have only one point of failure
also reduces the risk of getting throttled
  • Loading branch information
mifi committed Dec 1, 2023
1 parent d559384 commit 502c355
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 92 deletions.
59 changes: 53 additions & 6 deletions test/integration/__tests__/live-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ const { pipeline: streamPipeline } = require('stream')
const got = require('got')
const intoStream = require('into-stream')
const uuid = require('uuid')
const debug = require('debug')('transloadit:live-api')

const pipeline = promisify(streamPipeline)

const Transloadit = require('../../../src/Transloadit')

const { startTestServer } = require('../../testserver')
const { createTestServer } = require('../../testserver')

async function downloadTmpFile(url) {
const { path } = await temp.open('transloadit')
Expand Down Expand Up @@ -105,6 +106,52 @@ const genericOptions = {

jest.setTimeout(100000)

const handlers = new Map()

let testServer

beforeAll(async () => {
// cloudflared tunnels are a bit unstable, so we share one cloudflared tunnel between all tests
// we do this by prefixing each "virtual" server under a uuid subpath
testServer = await createTestServer((req, res) => {
const regex = /^\/([^/]+)/
const match = req.url.match(regex)
if (match) {
const [, id] = match
const handler = handlers.get(id)
if (handler) {
req.url = req.url.replace(regex, '')
if (req.url === '') req.url = '/'
handler(req, res)
} else {
debug('request handler for UUID not found', id)
}
} else {
debug('Invalid path match', req.url)
}
})
})

afterAll(async () => {
await testServer?.close()
})

async function createVirtualTestServer(handler) {
const id = uuid.v4()
debug('Adding virtual server handler', id)
const url = `${testServer.url}/${id}`
handlers.set(id, handler)

function close() {
handlers.delete(id)
}

return {
close,
url,
}
}

describe('API integration', () => {
describe('assembly creation', () => {
it('should create a retrievable assembly on the server', async () => {
Expand Down Expand Up @@ -393,7 +440,7 @@ describe('API integration', () => {
got.stream(genericImg).pipe(res)
}

const server = await startTestServer(handleRequest)
const server = await createVirtualTestServer(handleRequest)

try {
const params = {
Expand Down Expand Up @@ -448,7 +495,7 @@ describe('API integration', () => {
const awaitCompletionResponse = await awaitCompletionPromise
expect(awaitCompletionResponse.ok).toBe('ASSEMBLY_CANCELED')
} finally {
await server.close()
server.close()
}
})
})
Expand Down Expand Up @@ -510,8 +557,8 @@ describe('API integration', () => {

describe('assembly notification', () => {
let server
afterEach(async () => {
if (server) await server.close()
afterEach(() => {
server?.close()
})

// helper function
Expand Down Expand Up @@ -548,7 +595,7 @@ describe('API integration', () => {
}

try {
server = await startTestServer(onNotificationRequest)
server = await createVirtualTestServer(onNotificationRequest)
await createAssembly(client, { params: { ...genericParams, notify_url: server.url } })
} catch (err) {
onError(err)
Expand Down
139 changes: 70 additions & 69 deletions test/testserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,24 @@ const debug = require('debug')('transloadit:testserver')

const createTunnel = require('./tunnel')

async function startTestServer(handler2) {
let customHandler
async function createHttpServer(handler) {
return new Promise((resolve, reject) => {
const server = http.createServer(handler)

function handler(...args) {
if (customHandler) return customHandler(...args)
return handler2(...args)
}

const server = http.createServer(handler)
let tunnel

async function cleanup() {
await new Promise((resolve) => server.close(() => resolve()))
if (tunnel) await tunnel.close()
}
let port = 8000

// Find a free port to use
let port = 8000
await new Promise((resolve, reject) => {
// Find a free port to use
function tryListen() {
server.listen(port, '127.0.0.1', () => {
debug(`server listening on port ${port}`)
resolve()
resolve({ server, port })
})
}
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
if (++port >= 65535) {
server.close()
reject(new Error('Failed to bind to port'))
reject(new Error('Failed to find any free port to listen on'))
return
}
tryListen()
Expand All @@ -44,75 +32,88 @@ async function startTestServer(handler2) {

tryListen()
})
}

try {
if (!process.env.CLOUDFLARED_PATH) {
throw new Error('CLOUDFLARED_PATH environment variable not set')
}
async function createTestServer(onRequest) {
if (!process.env.CLOUDFLARED_PATH) {
throw new Error('CLOUDFLARED_PATH environment variable not set')
}

tunnel = createTunnel({ cloudFlaredPath: process.env.CLOUDFLARED_PATH, port })
let expectedPath
let initialized = false
let onTunnelOperational
let tunnel

// eslint-disable-next-line no-console
tunnel.process.on('error', console.error)
tunnel.process.on('close', () => {
// console.log('tunnel closed')
server.close()
})
const handleHttpRequest = (req, res) => {
debug('HTTP request handler', req.method, req.url)

debug('waiting for tunnel to be created')
const url = await tunnel.urlPromise
debug('tunnel created', url)
if (!initialized) {
if (req.url !== expectedPath) throw new Error(`Unexpected path ${req.url}`)
initialized = true
onTunnelOperational()
res.end()
} else {
onRequest(req, res)
}
}

try {
let curPath
let done = false
const { server, port } = await createHttpServer(handleHttpRequest)

const promise1 = new Promise((resolve) => {
customHandler = (req, res) => {
debug('handler', req.url)
async function close() {
if (tunnel) await tunnel.close()
server.closeAllConnections()
await new Promise((resolve) => server.close(() => resolve()))
debug('closed tunnel')
}

if (req.url !== curPath) throw new Error(`Unexpected path ${req.url}`)
try {
tunnel = createTunnel({ cloudFlaredPath: process.env.CLOUDFLARED_PATH, port })

done = true
res.end()
resolve()
}
})
debug('waiting for tunnel to be created')
const tunnelPublicUrl = await tunnel.urlPromise
debug('tunnel created', tunnelPublicUrl)

const promise2 = (async () => {
// try connecting to the tunnel and resolve when connection successfully passed through
for (let i = 0; i < 10; i += 1) {
if (done) return
curPath = `/check${i}`
try {
await got(`${url}${curPath}`, { timeout: { request: 2000 } })
return
} catch (err) {
// console.error(err.message)
// eslint-disable-next-line no-shadow
await new Promise((resolve) => setTimeout(resolve, 3000))
}
}
throw new Error('Timed out checking for a functioning tunnel')
})()
debug('Waiting for tunnel to allow requests to pass through')

await Promise.all([promise1, promise2])
} finally {
customHandler = undefined
// eslint-disable-next-line no-inner-declarations
async function sendTunnelRequest() {
// try connecting to the tunnel and resolve when connection successfully passed through
for (let i = 0; i < 10; i += 1) {
if (initialized) return

expectedPath = `/initialize-test${i}`
try {
await got(`${tunnelPublicUrl}${expectedPath}`, { timeout: { request: 2000 } })
return
} catch (err) {
// console.error(err.message)
// eslint-disable-next-line no-shadow
await new Promise((resolve) => setTimeout(resolve, 3000))
}
}
throw new Error('Timed out checking for an operational tunnel')
}

debug('Tunnel ready', url)
await Promise.all([
new Promise((resolve) => {
onTunnelOperational = resolve
}),
sendTunnelRequest(),
])

debug('Tunnel ready', tunnelPublicUrl)

return {
url,
close: () => cleanup(),
port,
close,
url: tunnelPublicUrl,
}
} catch (err) {
cleanup()
await close()
throw err
}
}

module.exports = {
startTestServer,
createTestServer,
}
36 changes: 19 additions & 17 deletions test/tunnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,21 @@ module.exports = ({ cloudFlaredPath = 'cloudflared', port }) => {
)
const rl = readline.createInterface({ input: process.stderr })

process.on('error', (err) => console.error(err))
process.on('error', (err) => {
console.error(err)
// todo recreate tunnel if it fails during operation?
})

let fullStderr = ''

const urlPromise = (async () => {
const url = await new Promise((resolve) => {
const url = await new Promise((resolve, reject) => {
let foundUrl

rl.on('error', (err) => {
process.kill()
throw new Error(
`Failed to create tunnel. Errored out on: ${err}. Full stderr: ${fullStderr}`
reject(
new Error(`Failed to create tunnel. Errored out on: ${err}. Full stderr: ${fullStderr}`)
)
})

Expand All @@ -38,12 +41,12 @@ module.exports = ({ cloudFlaredPath = 'cloudflared', port }) => {
fullStderr += `${line}\n`

if (
line.includes('failed') &&
line.toLocaleLowerCase().includes('failed') &&
!expectedFailures.some((expectedFailure) => line.includes(expectedFailure))
) {
process.kill()
throw new Error(
`Failed to create tunnel. There was an error string in the stderr: ${line}`
reject(
new Error(`Failed to create tunnel. There was an error string in the stderr: ${line}`)
)
}

Expand All @@ -65,29 +68,28 @@ module.exports = ({ cloudFlaredPath = 'cloudflared', port }) => {
// If we don't, the operating system's dns cache will be poisoned by the not yet valid resolved entry
// and it will forever fail for that subdomain name...
const resolver = new Resolver()
resolver.setServers(['8.8.8.8']) // if we don't explicitly specify DNS server, it will also poison the OS dns cache
resolver.setServers(['1.1.1.1']) // use cloudflare's dns server. if we don't explicitly specify DNS server, it will also poison our OS' dns cache
const resolve4 = promisify(resolver.resolve4.bind(resolver))
for (let i = 0; i < 10; i += 1) {

for (let i = 0; i < 20; i += 1) {
try {
const host = new URL(url).hostname
// console.log('checking dns', host)
debug('checking dns', host)
await resolve4(host)
return url
} catch (err) {
// console.error(err.message)
await new Promise((resolve) => setTimeout(resolve, 3000))
debug('dns err', err.message)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}

throw new Error('Timed out trying to resolve tunnel dns')
})()

async function close() {
const promise = new Promise((resolve) => process.on('close', resolve))
process.kill()
try {
await process
} catch (err) {
// ignored
}
await promise
}

return {
Expand Down

0 comments on commit 502c355

Please sign in to comment.