diff --git a/packages/bridge/src/runtime/composables/router.ts b/packages/bridge/src/runtime/composables/router.ts index d8babf65e..0a7d452cf 100644 --- a/packages/bridge/src/runtime/composables/router.ts +++ b/packages/bridge/src/runtime/composables/router.ts @@ -1,13 +1,26 @@ import { getCurrentInstance, reactive } from 'vue' import type VueRouter from 'vue-router' import type { Location, RawLocation, Route, NavigationFailure } from 'vue-router' -import { sendRedirect } from 'h3' +import { sanitizeStatusCode } from 'h3' import { useRouter as useVueRouter, useRoute as useVueRoute } from 'vue-router/composables' import { hasProtocol, joinURL, parseURL, withQuery } from 'ufo' import { useNuxtApp, callWithNuxt, useRuntimeConfig } from '../nuxt' import { createError, showError } from './error' import type { NuxtError } from './error' +const URL_QUOTE_RE = /"/g + +export function encodeURL (location: string, isExternalHost = false) { + const url = new URL(location, 'http://localhost') + if (!isExternalHost) { + return url.pathname + url.search + url.hash + } + if (location.startsWith('//')) { + return url.toString().replace(url.protocol, '') + } + return url.toString() +} + // Auto-import equivalents for `vue-router` export const useRouter = () => { if (getCurrentInstance()) { @@ -138,11 +151,20 @@ export const navigateTo = (to: RawLocation | undefined | null, options?: Navigat const fullPath = typeof to === 'string' || isExternal ? toPath : router.resolve(to).resolved.fullPath || '/' const location = isExternal ? toPath : joinURL(useRuntimeConfig().app.baseURL, fullPath) + const isExternalHost = hasProtocol(toPath, { acceptRelative: true }) + const redirect = async function (response: any) { // @ts-expect-error await nuxtApp.callHook('app:redirected') - await sendRedirect(nuxtApp.ssrContext!.event, location, options?.redirectCode || 302) + const encodedLoc = location.replace(URL_QUOTE_RE, '%22') + const encodedHeader = encodeURL(location, isExternalHost) + + nuxtApp.ssrContext!['~renderResponse'] = { + statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302), + body: ``, + headers: { location: encodedHeader } + } return response } diff --git a/packages/bridge/src/runtime/nitro/renderer.ts b/packages/bridge/src/runtime/nitro/renderer.ts index 660575f44..6c75afdc1 100644 --- a/packages/bridge/src/runtime/nitro/renderer.ts +++ b/packages/bridge/src/runtime/nitro/renderer.ts @@ -45,6 +45,7 @@ interface NuxtSSRContext extends SSRContext { url: string noSSR: boolean redirected?: boolean + '~renderResponse'?: RenderResponse event: H3Event /** @deprecated use `ssrContext.event` instead */ req: H3Event['req'] @@ -198,6 +199,12 @@ export default defineRenderHandler(async (event) => { // If we error on rendering error page, we bail out and directly return to the error handler if (!_rendered) { return } + await ssrContext.nuxtApp?.hooks.callHook('app:rendered', { ssrContext, renderResult: _rendered }) + + if (ssrContext['~renderResponse']) { + return ssrContext['~renderResponse'] + } + if (ssrContext.redirected || event.node.res.writableEnded) { return } @@ -207,8 +214,6 @@ export default defineRenderHandler(async (event) => { throw ssrContext.nuxt.error } - ssrContext.nuxtApp?.hooks.callHook('app:rendered', { ssrContext, renderResult: _rendered }) - ssrContext.nuxt = ssrContext.nuxt || {} if (process.env.NUXT_FULL_STATIC) { diff --git a/playground/pages/cookie-with-redirect.vue b/playground/pages/cookie-with-redirect.vue new file mode 100644 index 000000000..27f7b201f --- /dev/null +++ b/playground/pages/cookie-with-redirect.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 29ad94b6b..ad1e12094 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -54,6 +54,20 @@ describe('nuxt composables', () => { expect(await extractCookie()).toEqual({ foo: 'baz' }) await page.close() }) + + it('should respond with set-cookie header even when SSR redirect occurs', async () => { + const res = await fetch('/cookie-with-redirect', { redirect: 'manual' }) + + // Verify redirect occurred + expect(res.status).toBe(302) + expect(res.headers.get('location')).toEqual('/') + + // Verify cookies were set + const cookies = res.headers.get('set-cookie') + expect(cookies).toBeTruthy() + expect(cookies).toContain('redirect-test=foo') + }) + it('error should be render', async () => { const html = await $fetch('/async-data')