From 651341a75854e10b8f0caafcdbfaaf991b0d379d Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Thu, 20 Nov 2025 16:52:00 -0500 Subject: [PATCH 1/9] feat: rewrite redirects sent through the proxy --- playground/nuxt.config.ts | 3 + playground/server/api/custom/echo.ts | 9 +++ playground/server/api/custom/redirect.ts | 8 +++ src/module.ts | 31 ++++++++--- src/runtime/server/handler.ts | 4 ++ src/runtime/server/proxyHandler.ts | 71 +++++++++++++++++++++++- src/types.d.ts | 11 ++++ test/e2e-proxy.test.ts | 27 ++++++++- test/fixture/server/api/redirect.get.ts | 17 ++++++ 9 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 playground/server/api/custom/echo.ts create mode 100644 playground/server/api/custom/redirect.ts create mode 100644 src/types.d.ts create mode 100644 test/fixture/server/api/redirect.get.ts diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 507e88c..6e2cbcc 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -35,6 +35,9 @@ export default defineNuxtConfig({ url: 'https://petstore3.swagger.io/api/v3', schema: './schemas/petStore.yaml', }, + custom: { + url: '/api/custom', + }, }, }, }) diff --git a/playground/server/api/custom/echo.ts b/playground/server/api/custom/echo.ts new file mode 100644 index 0000000..66ca582 --- /dev/null +++ b/playground/server/api/custom/echo.ts @@ -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, + } +}) diff --git a/playground/server/api/custom/redirect.ts b/playground/server/api/custom/redirect.ts new file mode 100644 index 0000000..1214788 --- /dev/null +++ b/playground/server/api/custom/redirect.ts @@ -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) +}) diff --git a/src/module.ts b/src/module.ts index 1fa35c4..a1bb724 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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' @@ -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 @@ -195,6 +212,7 @@ export default defineNuxtModule().with({ enablePrefixedProxy: false, disableClientPayloadCache: false, enableSchemaFileWatcher: true, + rewriteProxyRedirects: true, }, }, async setup(options, nuxt) { @@ -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 + 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(), }) diff --git a/src/runtime/server/handler.ts b/src/runtime/server/handler.ts index 8baa835..b7275cd 100644 --- a/src/runtime/server/handler.ts +++ b/src/runtime/server/handler.ts @@ -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) + } }, }, ) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index 6c3e4a8..8888889 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -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, parsePath, parseURL, withoutBase, withQuery } from 'ufo' + +const REDIRECT_CODES = new Set([201, 301, 302, 303, 307, 308]) export default defineEventHandler(async (event) => { const nitro = useNitroApp() @@ -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 @@ -65,17 +70,79 @@ 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 + * @param opts.path The path of the proxied request + */ +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.host ||= reqUrl.host + + let cleanRedirect + if (locUrl.host === baseUrl.host) { + // same origin full URL + cleanRedirect = withoutBase(`${locUrl.pathname}${locUrl.search}${locUrl.hash}`, baseUrl.pathname) + } + else if (hasLeadingSlash(location)) { + // rewrite absolute paths to be relative to the proxied endpoint path + cleanRedirect = withoutBase(location, parsePath(baseURL).pathname) + } + else { + // relative path or cross-origin URL, leave as-is + return + } + + if (cleanRedirect === location) { + throw createError({ + statusCode: 500, + message: `Cannot rewrite redirect '${location}' as it is outside of the endpoint path.`, + }) + } + + 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) + } +} + interface HookErrorPromise extends Promise { wrap:

(fn: (...args: P) => Promise) => (...args: P) => Promise } diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..e05bc2c --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,11 @@ +declare module '#nuxt-api-party.nitro-config' { + export const experimentalRewriteProxyRedirects: boolean +} + +declare module '#build/module/nuxt-api-party.config' { + export declare const allowClient: boolean | 'allow' | 'always' + export declare const serverBasePath: string + + export declare const experimentalEnablePrefixedProxy: boolean + export declare const experimentalDisableClientPayloadCache: boolean +} diff --git a/test/e2e-proxy.test.ts b/test/e2e-proxy.test.ts index 4fc1da7..177129e 100644 --- a/test/e2e-proxy.test.ts +++ b/test/e2e-proxy.test.ts @@ -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 () => { @@ -23,4 +23,29 @@ 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(500) + const data = await response.json() + expect(data.message).toBe('Cannot rewrite redirect \'/\' as it is outside of the endpoint path.') + }) + }) }) diff --git a/test/fixture/server/api/redirect.get.ts b/test/fixture/server/api/redirect.get.ts new file mode 100644 index 0000000..02b0b81 --- /dev/null +++ b/test/fixture/server/api/redirect.get.ts @@ -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') +}) From cd273b4fc9d1f2580143b370e397e7ad9e18e7e2 Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Fri, 21 Nov 2025 12:45:38 -0500 Subject: [PATCH 2/9] fix: check for outside locations without host --- src/runtime/server/proxyHandler.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index 8888889..4a1dd4c 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -118,24 +118,17 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str let cleanRedirect if (locUrl.host === baseUrl.host) { // same origin full URL - cleanRedirect = withoutBase(`${locUrl.pathname}${locUrl.search}${locUrl.hash}`, baseUrl.pathname) + 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 = withoutBase(location, parsePath(baseURL).pathname) + cleanRedirect = cleanRedirectLocation(location, parsePath(baseURL).pathname) } else { // relative path or cross-origin URL, leave as-is return } - if (cleanRedirect === location) { - throw createError({ - statusCode: 500, - message: `Cannot rewrite redirect '${location}' as it is outside of the endpoint path.`, - }) - } - const routePrefix = reqUrl.pathname.slice(0, reqUrl.pathname.length - path.length) const newLocation = joinURL(routePrefix, cleanRedirect) event.node.res.setHeader('x-original-location', location) @@ -143,6 +136,17 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str } } +function cleanRedirectLocation(location: string, baseURL: string) { + const newLocation = withoutBase(location, baseURL) + if (newLocation === location) { + throw createError({ + statusCode: 500, + message: `Cannot rewrite redirect '${location}' as it is outside of the endpoint base URL.`, + }) + } + return newLocation +} + interface HookErrorPromise extends Promise { wrap:

(fn: (...args: P) => Promise) => (...args: P) => Promise } From bd118c40436be600b2da89d2e3a04a5750c6e812 Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Fri, 21 Nov 2025 12:46:15 -0500 Subject: [PATCH 3/9] fix: ensure protocol matches when checking same origin --- src/runtime/server/proxyHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index 4a1dd4c..1f55443 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -116,7 +116,7 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str baseUrl.host ||= reqUrl.host let cleanRedirect - if (locUrl.host === baseUrl.host) { + if (locUrl.host === baseUrl.host && locUrl.protocol === baseUrl.protocol) { // same origin full URL cleanRedirect = cleanRedirectLocation(`${locUrl.pathname}${locUrl.search}${locUrl.hash}`, baseUrl.pathname) } From 9c96f2b468ee7d2928e04dc9c89af7d0565e4e7f Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Fri, 21 Nov 2025 12:49:49 -0500 Subject: [PATCH 4/9] fix: tests --- src/runtime/server/proxyHandler.ts | 3 ++- test/e2e-proxy.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index 1f55443..156bda5 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -113,6 +113,7 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str const reqUrl = getRequestURL(event) const locUrl = parseURL(location) const baseUrl = parseURL(baseURL) + baseUrl.protocol ||= reqUrl.protocol baseUrl.host ||= reqUrl.host let cleanRedirect @@ -122,7 +123,7 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str } else if (hasLeadingSlash(location)) { // rewrite absolute paths to be relative to the proxied endpoint path - cleanRedirect = cleanRedirectLocation(location, parsePath(baseURL).pathname) + cleanRedirect = cleanRedirectLocation(location, baseUrl.pathname) } else { // relative path or cross-origin URL, leave as-is diff --git a/test/e2e-proxy.test.ts b/test/e2e-proxy.test.ts index 177129e..015af55 100644 --- a/test/e2e-proxy.test.ts +++ b/test/e2e-proxy.test.ts @@ -45,7 +45,7 @@ describe('nuxt-api-party proxy', async () => { }) expect(response.status).toBe(500) const data = await response.json() - expect(data.message).toBe('Cannot rewrite redirect \'/\' as it is outside of the endpoint path.') + expect(data.message).toBe('Cannot rewrite redirect \'/\' as it is outside of the endpoint base URL.') }) }) }) From 9cda09bfad96315933536447d4c7505e41f260d9 Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Fri, 21 Nov 2025 12:51:04 -0500 Subject: [PATCH 5/9] fix: imports --- src/runtime/server/proxyHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index 156bda5..cc16a1b 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -11,7 +11,7 @@ import { proxyRequest, } from 'h3' import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime' -import { hasLeadingSlash, joinURL, parsePath, parseURL, withoutBase, withQuery } from 'ufo' +import { hasLeadingSlash, joinURL, parseURL, withoutBase, withQuery } from 'ufo' const REDIRECT_CODES = new Set([201, 301, 302, 303, 307, 308]) From 54cc9d46e076805aa889e0f65a692ce255c353ee Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Mon, 24 Nov 2025 10:13:16 -0500 Subject: [PATCH 6/9] fix: improve jsdocs, change 500 error to 502 --- src/runtime/server/proxyHandler.ts | 15 ++++++++++++--- test/e2e-proxy.test.ts | 1 - 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index cc16a1b..ca2cf71 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -104,8 +104,8 @@ export default defineEventHandler(async (event) => { * * @param event The H3 event * @param opts - * @param opts.baseURL The base URL of the proxied endpoint - * @param opts.path The path of the proxied request + * @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 @@ -137,11 +137,20 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str } } +/** + * Clean a redirect location by removing the base URL. + * + * If the location is outside of the base URL, a 500 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: 500, + statusCode: 502, // Bad Gateway message: `Cannot rewrite redirect '${location}' as it is outside of the endpoint base URL.`, }) } diff --git a/test/e2e-proxy.test.ts b/test/e2e-proxy.test.ts index 015af55..70509ba 100644 --- a/test/e2e-proxy.test.ts +++ b/test/e2e-proxy.test.ts @@ -41,7 +41,6 @@ describe('nuxt-api-party proxy', async () => { 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(500) const data = await response.json() From 33e594c987ce1e66cfedabec97115582b92a5681 Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Mon, 24 Nov 2025 10:17:41 -0500 Subject: [PATCH 7/9] fix: update error code in cleanRedirectLocation function from 500 to 502 --- src/runtime/server/proxyHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/server/proxyHandler.ts b/src/runtime/server/proxyHandler.ts index ca2cf71..4800eb7 100644 --- a/src/runtime/server/proxyHandler.ts +++ b/src/runtime/server/proxyHandler.ts @@ -140,7 +140,7 @@ function rewriteProxyRedirects(event: H3Event, { baseURL, path }: { baseURL: str /** * Clean a redirect location by removing the base URL. * - * If the location is outside of the base URL, a 500 error is thrown. + * 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 From 2a5b639a9bddfcabf4454d2659d49119f575d57d Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Mon, 24 Nov 2025 10:19:59 -0500 Subject: [PATCH 8/9] fix: update export declarations in types.d.ts for consistency --- src/types.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/types.d.ts b/src/types.d.ts index e05bc2c..8b429e0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -3,9 +3,9 @@ declare module '#nuxt-api-party.nitro-config' { } declare module '#build/module/nuxt-api-party.config' { - export declare const allowClient: boolean | 'allow' | 'always' - export declare const serverBasePath: string + export const allowClient: boolean | 'allow' | 'always' + export const serverBasePath: string - export declare const experimentalEnablePrefixedProxy: boolean - export declare const experimentalDisableClientPayloadCache: boolean + export const experimentalEnablePrefixedProxy: boolean + export const experimentalDisableClientPayloadCache: boolean } From 719cabce27e0de4c0607043e97f62b3ed74f49ba Mon Sep 17 00:00:00 2001 From: Matthew Messinger Date: Mon, 24 Nov 2025 10:35:56 -0500 Subject: [PATCH 9/9] fix: update error code in redirect test from 500 to 502 --- test/e2e-proxy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e-proxy.test.ts b/test/e2e-proxy.test.ts index 70509ba..478933e 100644 --- a/test/e2e-proxy.test.ts +++ b/test/e2e-proxy.test.ts @@ -42,7 +42,7 @@ describe('nuxt-api-party proxy', async () => { const response = await fetch('/api/__api_party/testApi/proxy/redirect?mode=outside', { redirect: 'manual', }) - expect(response.status).toBe(500) + 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.') })