Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ Note that the [rewriteHeaders](https://github.com/fastify/fastify-reply-from#rew

An array that contains the types of the methods. Default: `['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']`.

Methods that are already registered for one of the proxied routes are skipped (a `debug` log entry lists the skipped methods). This allows plugins such as [`@fastify/cors`](https://github.com/fastify/fastify-cors), which registers an `OPTIONS /*` route to answer preflight requests, to be used together with this plugin. Such plugins must be registered **before** `@fastify/http-proxy`:

```js
await server.register(require('@fastify/cors'), { origin: '*' })
await server.register(require('@fastify/http-proxy'), {
upstream: 'http://my-api.example.com'
})
```

If you register them in the opposite order, remove `OPTIONS` from `httpMethods` instead, so that preflight requests are not proxied.

### `routes`

An array that contains the routes to handle. Default: `['/', '/*']`.
Expand Down
41 changes: 40 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,46 @@ async function fastifyHttpProxy (fastify, opts) {
const constraints = opts.constraints || {}

for (const url of opts.routes || defaultRoutes) {
fastify.route({ url, method, preHandler, config, constraints, handler })
// Skip methods already registered for this url, e.g. the OPTIONS '/*'
// preflight route added by @fastify/cors, to avoid a duplicated route error.
const fullUrls = joinPrefix(fastify.prefix, url)
const methods = []
const skipped = []
for (const m of [].concat(method)) {
if (fullUrls.some(fullUrl => fastify.hasRoute({ url: fullUrl, method: m, constraints }))) {
skipped.push(m)
} else {
methods.push(m)
}
}
if (skipped.length > 0) {
fastify.log.debug(
{ url: fullUrls[0], methods: skipped },
'@fastify/http-proxy: skipping methods already registered for route'
)
}
if (methods.length > 0) {
fastify.route({ url, method: methods, preHandler, config, constraints, handler })
}
}

// Mirrors how Fastify joins the plugin prefix and the route url, so that
// the lookup above targets the very same paths `fastify.route()` registers.
function joinPrefix (prefix, url) {
if (url === '/' && prefix.length > 0) {
// '/prefix' is always registered, '/prefix/' only when the router
// options do not make it redundant
const { ignoreTrailingSlash, ignoreDuplicateSlashes } = prefixMatchingOptions
if (ignoreTrailingSlash !== true && (ignoreDuplicateSlashes !== true || !prefix.endsWith('/'))) {
return [prefix, prefix + '/']
}
return [prefix]
}
if (url[0] === '/' && prefix.endsWith('/')) {
// '/prefix/' + '/route' is registered as '/prefix/route'
return [prefix + url.slice(1)]
}
return [prefix + url]
}

let wsProxy
Expand Down
198 changes: 198 additions & 0 deletions test/existing-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
'use strict'

const { test } = require('node:test')
const Fastify = require('fastify')
const proxy = require('../')

async function createOrigin (t) {
const origin = Fastify()
origin.get('/', async () => 'this is root')
origin.get('/a', async () => 'this is a')
origin.options('/a', async (_request, reply) => {
reply.header('x-from', 'origin')
return ''
})
await origin.listen({ port: 0 })
t.after(() => origin.close())
return `http://localhost:${origin.server.address().port}`
}

test('skips OPTIONS when an OPTIONS route is already registered (e.g. @fastify/cors)', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

// Same routes @fastify/cors registers to answer preflight requests
server.options('/*', async (_request, reply) => {
reply.header('x-from', 'preflight').code(204).send()
})
server.options('/', async (_request, reply) => {
reply.header('x-from', 'preflight').code(204).send()
})

await server.register(proxy, { upstream })
await server.ready()

const preflight = await server.inject({ method: 'OPTIONS', url: '/a' })
t.assert.strictEqual(preflight.statusCode, 204)
t.assert.strictEqual(preflight.headers['x-from'], 'preflight')

const res = await server.inject({ method: 'GET', url: '/a' })
t.assert.strictEqual(res.statusCode, 200)
t.assert.strictEqual(res.body, 'this is a')
})

test('proxies OPTIONS when no OPTIONS route is registered', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

await server.register(proxy, { upstream })

const res = await server.inject({ method: 'OPTIONS', url: '/a' })
t.assert.strictEqual(res.statusCode, 200)
t.assert.strictEqual(res.headers['x-from'], 'origin')
})

test('skips a route when all its methods are already registered', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

server.get('/*', async () => 'local')

await server.register(proxy, { upstream, httpMethods: ['GET'] })
await server.ready()

const res = await server.inject({ method: 'GET', url: '/a' })
t.assert.strictEqual(res.body, 'local')

// '/' is not covered by the local '/*' route, so it is still proxied
const root = await server.inject({ method: 'GET', url: '/' })
t.assert.strictEqual(root.body, 'this is root')
})

test('accepts httpMethods as a string', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

await server.register(proxy, { upstream, httpMethods: 'GET' })

const res = await server.inject({ method: 'GET', url: '/a' })
t.assert.strictEqual(res.body, 'this is a')
})

test('works with a prefix alongside a global OPTIONS route', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

server.options('/*', async (_request, reply) => {
reply.header('x-from', 'preflight').code(204).send()
})

await server.register(proxy, { upstream, prefix: '/api' })

const res = await server.inject({ method: 'GET', url: '/api/a' })
t.assert.strictEqual(res.body, 'this is a')

const preflight = await server.inject({ method: 'OPTIONS', url: '/api/a' })
t.assert.strictEqual(preflight.headers['x-from'], 'origin')
})

test('logs the skipped methods at debug level', async (t) => {
const upstream = await createOrigin(t)
const logs = []
const server = Fastify({
logger: {
level: 'debug',
stream: { write (line) { logs.push(JSON.parse(line)) } }
}
})
t.after(() => server.close())

server.options('/*', async (_request, reply) => {
reply.code(204).send()
})

await server.register(proxy, { upstream })
await server.ready()

const entry = logs.find(l => l.msg === '@fastify/http-proxy: skipping methods already registered for route')
t.assert.ok(entry, 'a debug log entry is emitted')
t.assert.strictEqual(entry.level, 20)
t.assert.strictEqual(entry.url, '/*')
t.assert.deepStrictEqual(entry.methods, ['OPTIONS'])
})

test('skips already registered routes when the prefix has a trailing slash', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

server.options('/api/*', async (_request, reply) => {
reply.header('x-from', 'preflight').code(204).send()
})

await server.register(proxy, { upstream, prefix: '/api/' })
await server.ready()

const preflight = await server.inject({ method: 'OPTIONS', url: '/api/a' })
t.assert.strictEqual(preflight.statusCode, 204)
t.assert.strictEqual(preflight.headers['x-from'], 'preflight')

const res = await server.inject({ method: 'GET', url: '/api/a' })
t.assert.strictEqual(res.body, 'this is a')
})

test('skips already registered routes when the route url has no leading slash', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

server.get('/prefix/foourl', async () => 'local')

await server.register(proxy, { upstream, prefix: '/prefix/', routes: ['foourl', '/*'], httpMethods: ['GET'] })
await server.ready()

const local = await server.inject({ method: 'GET', url: '/prefix/foourl' })
t.assert.strictEqual(local.body, 'local')

const res = await server.inject({ method: 'GET', url: '/prefix/a' })
t.assert.strictEqual(res.body, 'this is a')
})

test('skips the prefix root when it is already registered without trailing slash', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify()
t.after(() => server.close())

server.get('/api', async () => 'local')

await server.register(proxy, { upstream, httpMethods: ['GET'], prefix: '/api' })
await server.ready()

const local = await server.inject({ method: 'GET', url: '/api' })
t.assert.strictEqual(local.body, 'local')

const res = await server.inject({ method: 'GET', url: '/api/a' })
t.assert.strictEqual(res.body, 'this is a')
})

test('skips the prefix root with ignoreTrailingSlash enabled', async (t) => {
const upstream = await createOrigin(t)
const server = Fastify({ routerOptions: { ignoreTrailingSlash: true } })
t.after(() => server.close())

server.get('/api', async () => 'local')

await server.register(proxy, { upstream, httpMethods: ['GET'], prefix: '/api' })
await server.ready()

const local = await server.inject({ method: 'GET', url: '/api/' })
t.assert.strictEqual(local.body, 'local')

const res = await server.inject({ method: 'GET', url: '/api/a' })
t.assert.strictEqual(res.body, 'this is a')
})
Loading