Skip to content
3 changes: 3 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export default defineNuxtConfig({
url: 'https://petstore3.swagger.io/api/v3',
schema: './schemas/petStore.yaml',
},
custom: {
url: '/api/custom',
},
},
},
})
9 changes: 9 additions & 0 deletions playground/server/api/custom/echo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineEventHandler } from 'h3'

export default defineEventHandler(async (event) => {
return {
method: event.node.req.method,
url: event.node.req.url,
headers: event.node.req.headers,
}
})
8 changes: 8 additions & 0 deletions playground/server/api/custom/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineEventHandler, getQuery } from 'h3'

// clone of httpbin.org/redirect
export default defineEventHandler<{ query: { url: string, status_code?: number } }>(async (event) => {
const { url, status_code = 302 } = getQuery(event)
event.node.res.statusCode = Number(status_code)
event.node.res.setHeader('location', url)
})
31 changes: 22 additions & 9 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { OpenAPI3, OpenAPITSOptions } from 'openapi-typescript'
import type { QueryObject } from 'ufo'
import type { ApiClientFetchOptions, SharedFetchOptions } from './runtime/composables/$api'
import { fileURLToPath } from 'node:url'
import { addImportsSources, addServerHandler, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, updateTemplates, useLogger } from '@nuxt/kit'
import { addImportsSources, addServerHandler, addServerTemplate, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, updateTemplates, useLogger } from '@nuxt/kit'
import { watch } from 'chokidar'
import { defu } from 'defu'
import { createJiti } from 'jiti'
Expand Down Expand Up @@ -129,6 +129,23 @@ export interface ModuleOptions {
* @default true
*/
enableSchemaFileWatcher?: boolean

/**
* Enable proxy redirect rewriting.
*
* When enabled, redirect responses from API endpoints through the proxy will be rewritten, then
* forwarded to the client. This ensures that redirects are properly handled in both SSR and
* client-side contexts.
*
* When disabled, requests going through the proxy that result in redirects will be followed
* transparently, and the client will not know that a redirect occurred.
*
* Because of the way some redirects are handled by clients on POST requests, this option will be
* ignored if `enablePrefixedProxy` is disabled.
*
* @default true
*/
rewriteProxyRedirects?: boolean
}
}
// #endregion options
Expand Down Expand Up @@ -195,6 +212,7 @@ export default defineNuxtModule<ModuleOptions>().with({
enablePrefixedProxy: false,
disableClientPayloadCache: false,
enableSchemaFileWatcher: true,
rewriteProxyRedirects: true,
},
},
async setup(options, nuxt) {
Expand Down Expand Up @@ -462,15 +480,10 @@ export const experimentalDisableClientPayloadCache = ${JSON.stringify(options.ex
`.trimStart(),
})

addTemplate({
filename: `module/${moduleName}.config.d.ts`,
write: false, // Internal config, no need to write to disk
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out addTemplate forces write = true when the filename ends in .d.ts lol

addServerTemplate({
filename: `#${moduleName}.nitro-config`,
getContents: () => `
export declare const allowClient: boolean | 'allow' | 'always'
export declare const serverBasePath: string

export declare const experimentalEnablePrefixedProxy: boolean
export declare const experimentalDisableClientPayloadCache: boolean
export const experimentalRewriteProxyRedirects = ${JSON.stringify(options.experimental.rewriteProxyRedirects)}
`.trimStart(),
})

Expand Down
4 changes: 4 additions & 0 deletions src/runtime/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export default defineEventHandler(async (event) => {
// @ts-expect-error: Types will be generated on Nuxt prepare
await nitro.hooks.callHook(`api-party:response:${endpointId}`, ctx, event)
await nitro.hooks.callHook('api-party:response', ctx, event)

if (ctx.response.redirected) {
ctx.response.headers.set('x-redirected-to', ctx.response.url)
}
},
},
)
Expand Down
85 changes: 83 additions & 2 deletions src/runtime/server/proxyHandler.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { H3Error } from 'h3'
import type { H3Error, H3Event } from 'h3'
import { experimentalRewriteProxyRedirects } from '#nuxt-api-party.nitro-config'
import {
createError,
defineEventHandler,
getQuery,
getRequestHeader,
getRequestURL,
getRouterParam,
isError,
proxyRequest,
} from 'h3'
import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime'
import { joinURL, withQuery } from 'ufo'
import { hasLeadingSlash, joinURL, parseURL, withoutBase, withQuery } from 'ufo'

const REDIRECT_CODES = new Set([201, 301, 302, 303, 307, 308])

export default defineEventHandler(async (event) => {
const nitro = useNitroApp()
Expand Down Expand Up @@ -56,6 +60,7 @@ export default defineEventHandler(async (event) => {
hookErrorPromise,
proxyRequest(event, url, {
fetch: globalThis.$fetch.create({
redirect: experimentalRewriteProxyRedirects ? 'manual' : 'follow',
onRequest: hookErrorPromise.wrap(async (ctx) => {
await nitro.hooks.callHook('api-party:request', ctx, event)
// @ts-expect-error: Types will be generated on Nuxt prepare
Expand All @@ -65,17 +70,93 @@ export default defineEventHandler(async (event) => {
// @ts-expect-error: Types will be generated on Nuxt prepare
await nitro.hooks.callHook(`api-party:response:${endpointId}`, ctx, event)
await nitro.hooks.callHook('api-party:response', ctx, event)

if (ctx.response.redirected) {
ctx.response.headers.set('x-redirected-to', ctx.response.url)
}
}),
}).raw,
onResponse: (event) => {
if (!endpoint.cookies && event.node.res.hasHeader('set-cookie')) {
event.node.res.removeHeader('set-cookie')
}

if (experimentalRewriteProxyRedirects) {
const status = event.node.res.statusCode
if (REDIRECT_CODES.has(status)) {
rewriteProxyRedirects(event, { baseURL, path })
}
}
},
}),
])
})

/**
* Rewrite redirects for proxied requests.
*
* This rewrites relative redirects to be relative to the proxied
* endpoint path, and absolute redirects to be relative to the
* proxied endpoint base URL. If a relative redirect would point
* outside of the proxied endpoint path, an error is thrown.
*
* Cross-origin redirects are not rewritten.
*
* @param event The H3 event
* @param opts
* @param opts.baseURL The base URL of the proxied endpoint, used to build a full URL for absolute redirects
* @param opts.path The path of the proxied request, used to determine the route prefix
*/
function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: string, path: string }) {
const location = event.node.res.getHeader('location') as string | undefined
if (location) {
const reqUrl = getRequestURL(event)
const locUrl = parseURL(location)
const baseUrl = parseURL(baseURL)
baseUrl.protocol ||= reqUrl.protocol
baseUrl.host ||= reqUrl.host

let cleanRedirect
if (locUrl.host === baseUrl.host && locUrl.protocol === baseUrl.protocol) {
// same origin full URL
cleanRedirect = cleanRedirectLocation(`${locUrl.pathname}${locUrl.search}${locUrl.hash}`, baseUrl.pathname)
}
else if (hasLeadingSlash(location)) {
// rewrite absolute paths to be relative to the proxied endpoint path
cleanRedirect = cleanRedirectLocation(location, baseUrl.pathname)
}
else {
// relative path or cross-origin URL, leave as-is
return
}

const routePrefix = reqUrl.pathname.slice(0, reqUrl.pathname.length - path.length)
const newLocation = joinURL(routePrefix, cleanRedirect)
event.node.res.setHeader('x-original-location', location)
event.node.res.setHeader('location', newLocation)
}
}

/**
* Clean a redirect location by removing the base URL.
*
* If the location is outside of the base URL, a 502 error is thrown.
*
* @param location The location to clean
* @param baseURL The base url to remove from location
* @returns The cleaned location
*/
function cleanRedirectLocation(location: string, baseURL: string) {
const newLocation = withoutBase(location, baseURL)
if (newLocation === location) {
throw createError({
statusCode: 502, // Bad Gateway
message: `Cannot rewrite redirect '${location}' as it is outside of the endpoint base URL.`,
})
}
return newLocation
}

interface HookErrorPromise extends Promise<never> {
wrap: <P extends any[]>(fn: (...args: P) => Promise<void>) => (...args: P) => Promise<void>
}
Expand Down
11 changes: 11 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
declare module '#nuxt-api-party.nitro-config' {
export const experimentalRewriteProxyRedirects: boolean
}

declare module '#build/module/nuxt-api-party.config' {
export const allowClient: boolean | 'allow' | 'always'
export const serverBasePath: string

export const experimentalEnablePrefixedProxy: boolean
export const experimentalDisableClientPayloadCache: boolean
}
26 changes: 25 additions & 1 deletion test/e2e-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fileURLToPath } from 'node:url'
import { $fetch, setup } from '@nuxt/test-utils/e2e'
import { $fetch, fetch, setup } from '@nuxt/test-utils/e2e'
import { describe, expect, it } from 'vitest'

describe('nuxt-api-party proxy', async () => {
Expand All @@ -23,4 +23,28 @@ describe('nuxt-api-party proxy', async () => {
},
})
})

describe('redirect rewriting', () => {
it.each([
['protocol', 'https://jsonplaceholder.typicode.com/todos'],
['relative', 'todos'],
['absolute', '/api/__api_party/testApi/proxy/todos'],
['external', '/api/__api_party/testApi/proxy/todos'],
])(`%s redirect is rewritten`, async (mode, location) => {
const response = await fetch(`/api/__api_party/testApi/proxy/redirect?mode=${mode}`, {
redirect: 'manual',
})
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe(location)
})

it('throws error for redirect outside of proxied path', async () => {
const response = await fetch('/api/__api_party/testApi/proxy/redirect?mode=outside', {
redirect: 'manual',
})
expect(response.status).toBe(502)
const data = await response.json()
expect(data.message).toBe('Cannot rewrite redirect \'/\' as it is outside of the endpoint base URL.')
})
})
})
17 changes: 17 additions & 0 deletions test/fixture/server/api/redirect.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineEventHandler, getQuery, getRequestURL, setHeader, setResponseStatus } from 'h3'
import { withBase } from 'ufo'

export default defineEventHandler(async (event) => {
const mode = String(getQuery(event).mode)

const url = getRequestURL(event)

setResponseStatus(event, 302)
setHeader(event, 'location', {
relative: 'todos',
absolute: '/api/todos',
protocol: 'https://jsonplaceholder.typicode.com/todos',
outside: '/',
external: withBase('/api/todos', String(url.origin)),
}[mode] || '/api/todos')
})