From 6fd1b453d4464f1dc79a139ba46d05363576dd74 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Sun, 14 Sep 2025 09:29:38 +0900 Subject: [PATCH] feat(runtime): allow `registerEndpoint` to work with native fetch --- .../app-vitest-full/tests/nuxt/server.spec.ts | 107 +++++++++++++++++- src/environments/vitest/index.ts | 1 + src/runtime/shared/environment.ts | 49 ++++++-- 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/examples/app-vitest-full/tests/nuxt/server.spec.ts b/examples/app-vitest-full/tests/nuxt/server.spec.ts index 4d91181ce..0a24d7af0 100644 --- a/examples/app-vitest-full/tests/nuxt/server.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/server.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' import { listen } from 'listhen' -import { createApp, eventHandler, toNodeListener } from 'h3' +import { createApp, eventHandler, toNodeListener, readBody, getHeaders, getQuery } from 'h3' import FetchComponent from '~/components/FetchComponent.vue' @@ -71,4 +71,109 @@ describe('server mocks and data fetching', () => { }) expect(await $fetch('/method')).toMatchObject({ method: 'GET' }) }) + + it('can mock native fetch requests', async () => { + registerEndpoint('/native/1', { + method: 'POST', + handler: () => ({ path: '/native/1', method: 'POST' }), + }) + registerEndpoint('/native/1', { + method: 'GET', + handler: () => ({ path: '/native/1', method: 'GET' }), + }) + registerEndpoint('https://jsonplaceholder.typicode.com/native/1', { + method: 'GET', + handler: () => ({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'GET' }), + }) + registerEndpoint('https://jsonplaceholder.typicode.com/native/1', { + method: 'POST', + handler: () => ({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'POST' }), + }) + + expect(await fetch('/native/1').then(res => res.json())).toMatchObject({ path: '/native/1', method: 'GET' }) + expect(await fetch('/native/1', { method: 'POST' }).then(res => res.json())).toMatchObject({ path: '/native/1', method: 'POST' }) + expect(await fetch('https://jsonplaceholder.typicode.com/native/1').then(res => res.json())) + .toMatchObject({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'GET' }) + expect(await fetch('https://jsonplaceholder.typicode.com/native/1', { method: 'POST' }).then(res => res.json())) + .toMatchObject({ path: 'https://jsonplaceholder.typicode.com/native/1', method: 'POST' }) + }) + + it('can mock fetch requests with data', async () => { + registerEndpoint('/with-data', { + method: 'POST', + handler: async (event) => { + return { + body: await readBody(event), + headers: getHeaders(event), + } + }, + }) + + expect(await $fetch('/with-data', { + method: 'POST', + body: { data: 'data' }, + headers: { 'x-test': 'test' }, + })).toMatchObject({ + body: { data: 'data' }, + headers: { 'x-test': 'test' }, + }) + + expect(await fetch('/with-data', { + method: 'POST', + body: JSON.stringify({ data: 'data' }), + headers: { 'x-test': 'test', 'content-type': 'application/json' }, + }).then(res => res.json())).toMatchObject({ + body: { data: 'data' }, + headers: { 'x-test': 'test' }, + }) + }) + + it('can mock fetch requests with query', async () => { + registerEndpoint('/with-data', { + method: 'GET', + handler: async (event) => { + return { + query: getQuery(event), + } + }, + }) + + expect(await $fetch('/with-data', { query: { q: 1 } })).toMatchObject({ query: { q: '1' } }) + expect(await fetch('/with-data?q=1').then(res => res.json())).toMatchObject({ query: { q: '1' } }) + }) + + it('can mock fetch requests with Request', async () => { + registerEndpoint('http://localhost:3000/with-request', { + method: 'GET', + handler: (event) => { + return { title: 'with-request', data: getQuery(event) } + }, + }) + registerEndpoint('http://localhost:3000/with-request', { + method: 'POST', + handler: async (event) => { + return { title: 'with-request', data: await readBody(event) } + }, + }) + + const request = new Request('/with-request?q=1', { headers: { 'content-type': 'application/json' } }) + + expect(await $fetch(request)).toMatchObject({ title: 'with-request', data: { q: '1' } }) + expect(await fetch(request).then(res => res.json())).toMatchObject({ title: 'with-request', data: { q: '1' } }) + + expect(await $fetch(request, { method: 'POST', body: [1] })).toMatchObject({ title: 'with-request', data: [1] }) + expect(await fetch(request, { method: 'POST', body: '[1]' }).then(res => res.json())).toMatchObject({ title: 'with-request', data: [1] }) + }) + + it('can mock fetch requests with URL', async () => { + registerEndpoint('http://localhost:3000/with-url', { + method: 'GET', + handler: (event) => { + return { title: 'with-url', data: getQuery(event) } + }, + }) + + expect(await fetch(new URL('http://localhost:3000/with-url')).then(res => res.json())).toMatchObject({ title: 'with-url', data: {} }) + expect(await fetch(new URL('http://localhost:3000/with-url?q=1')).then(res => res.json())).toMatchObject({ title: 'with-url', data: { q: '1' } }) + }) }) diff --git a/src/environments/vitest/index.ts b/src/environments/vitest/index.ts index 2250b9d2f..12d95b18b 100644 --- a/src/environments/vitest/index.ts +++ b/src/environments/vitest/index.ts @@ -48,6 +48,7 @@ export default { const teardownWindow = await setupWindow(win, environmentOptions as any) const { keys, originals } = populateGlobal(global, win, { bindFunctions: true, + additionalKeys: ['fetch', 'Request'], }) return { diff --git a/src/runtime/shared/environment.ts b/src/runtime/shared/environment.ts index 959f42901..aa443ed9b 100644 --- a/src/runtime/shared/environment.ts +++ b/src/runtime/shared/environment.ts @@ -47,28 +47,55 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N const h3App = createApp() - if (!win.fetch) { + if (!win.fetch || !('Request' in win)) { await import('node-fetch-native/polyfill') // @ts-expect-error fetch polyfill win.URLSearchParams = globalThis.URLSearchParams + // @ts-expect-error fetch polyfill + win.Request ??= class Request extends globalThis.Request { + constructor(input: RequestInfo, init?: RequestInit) { + if (typeof input === 'string') { + super(new URL(input, win.location.origin), init) + } + else { + super(input, init) + } + } + } } const nodeHandler = toNodeListener(h3App) const registry = new Set() - win.fetch = async (url, init) => { - if (typeof url === 'string') { - const base = url.split('?')[0]! - if (registry.has(base) || registry.has(url)) { - url = '/_' + url - } - if (url.startsWith('/')) { - const response = await fetchNodeRequestHandler(nodeHandler, url, init) - return normalizeFetchResponse(response) + const _fetch = fetch + win.fetch = async (input, _init) => { + let url: string + let init = _init + if (typeof input === 'string') { + url = input + } + else if (input instanceof URL) { + url = input.toString() + } + else { + url = input.url + init = { + method: init?.method ?? input.method, + body: init?.body ?? input.body, + headers: init?.headers ?? input.headers, } } - return fetch(url, init) + + const base = url.split('?')[0]! + if (registry.has(base) || registry.has(url)) { + url = '/_' + url + } + if (url.startsWith('/')) { + const response = await fetchNodeRequestHandler(nodeHandler, url, init) + return normalizeFetchResponse(response) + } + return _fetch(input, _init) } // @ts-expect-error fetch types differ slightly