diff --git a/examples/app-vitest-full/package.json b/examples/app-vitest-full/package.json index fe7c36c10..834bdd559 100644 --- a/examples/app-vitest-full/package.json +++ b/examples/app-vitest-full/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@nuxt/test-utils": "latest", "@testing-library/vue": "8.1.0", + "h3-next": "npm:h3@^2.0.1-rc.6", "happy-dom": "20.0.11", "jsdom": "27.3.0", "listhen": "1.9.0", diff --git a/examples/app-vitest-full/tests/nuxt/server.spec.ts b/examples/app-vitest-full/tests/nuxt/server.spec.ts index ca5102f40..18a289c45 100644 --- a/examples/app-vitest-full/tests/nuxt/server.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/server.spec.ts @@ -3,16 +3,13 @@ import { describe, expect, it, vi } from 'vitest' import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' import { listen } from 'listhen' -import { createApp, eventHandler, toNodeListener, readBody, getHeaders, getQuery } from 'h3' +import { H3, eventHandler, toNodeListener, readBody, getHeaders, getQuery } from 'h3-next/generic' import FetchComponent from '~/components/FetchComponent.vue' describe('server mocks and data fetching', () => { it('can use $fetch', async () => { - const app = createApp().use( - '/todos/1', - eventHandler(() => ({ id: 1 })), - ) + const app = new H3().use('/todos/1', eventHandler(() => ({ id: 1 }))) const server = await listen(toNodeListener(app)) const urls = await server.getURLs() const { url } = urls[0]! @@ -54,7 +51,7 @@ describe('server mocks and data fetching', () => { expect(await $fetch('/overrides')).toStrictEqual({ title: 'first' }) unsubFirst() - await expect($fetch('/overrides')).rejects.toMatchInlineSnapshot(`[FetchError: [GET] "/overrides": 404 Cannot find any path matching /overrides.]`) + await expect($fetch('/overrides')).rejects.toMatchInlineSnapshot(`[FetchError: [GET] "/overrides": 404 Not Found]`) }) it('can mock fetch requests with explicit methods', async () => { diff --git a/package.json b/package.json index a72d045e3..1925b19a7 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "exsolve": "^1.0.8", "fake-indexeddb": "^6.2.5", "get-port-please": "^3.2.0", - "h3": "^1.15.4", + "h3-next": "npm:h3@^2.0.1-rc.6", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "node-fetch-native": "^1.6.7", @@ -181,7 +181,7 @@ "vue": "^3.5.25" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^20.11.1 || ^22.0.0 || >=24.0.0" }, "packageManager": "pnpm@10.26.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcde5a238..8834344c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,9 +51,9 @@ importers: get-port-please: specifier: ^3.2.0 version: 3.2.0 - h3: - specifier: ^1.15.4 - version: 1.15.4 + h3-next: + specifier: npm:h3@^2.0.1-rc.6 + version: h3@2.0.1-rc.6 happy-dom: specifier: '*' version: 20.0.11 @@ -340,6 +340,9 @@ importers: '@testing-library/vue': specifier: 8.1.0 version: 8.1.0(@vue/compiler-sfc@3.5.25)(vue@3.5.25(typescript@5.9.3)) + h3-next: + specifier: npm:h3@^2.0.1-rc.6 + version: h3@2.0.1-rc.6 happy-dom: specifier: 20.0.11 version: 20.0.11 @@ -4802,6 +4805,15 @@ packages: h3@1.15.4: resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} + h3@2.0.1-rc.6: + resolution: {integrity: sha512-kKLFVFNJlDVTbQjakz1ZTFSHB9+oi9+Khf0v7xQsUKU3iOqu2qmrFzTD56YsDvvj2nBgqVDphGRXB2VRursw4w==} + engines: {node: '>=20.11.1'} + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -6874,6 +6886,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.11: + resolution: {integrity: sha512-ELguG3ENDw5NKNmWHO3OGEjcgdxkCNvnMR22gKHEgRXuwiriap5RIYdummOaOiqUNcC5yU5txGCHWNm7KlHuAA==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -13063,6 +13078,11 @@ snapshots: ufo: 1.6.1 uncrypto: 0.1.3 + h3@2.0.1-rc.6: + dependencies: + rou3: 0.7.11 + srvx: 0.9.7 + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -15954,6 +15974,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.4 fsevents: 2.3.3 + rou3@0.7.11: {} + run-applescript@7.1.0: {} run-parallel@1.2.0: diff --git a/src/e2e/server.ts b/src/e2e/server.ts index aa48fd929..804ec4374 100644 --- a/src/e2e/server.ts +++ b/src/e2e/server.ts @@ -1,11 +1,13 @@ import { x } from 'tinyexec' import { getRandomPort, waitForPort } from 'get-port-please' import type { FetchOptions } from 'ofetch' -import { $fetch as _$fetch, fetch as _fetch } from 'ofetch' +import { fetch as _fetch, createFetch } from 'ofetch' import { resolve } from 'pathe' import { joinURL } from 'ufo' import { useTestContext } from './context' +const globalFetch = globalThis.fetch || _fetch + export interface StartServerOptions { env?: Record } @@ -84,10 +86,12 @@ export async function stopServer() { } export function fetch(path: string, options?: RequestInit) { - return _fetch(url(path), options) + return globalFetch(url(path), options) } -export const $fetch = function (path: string, options?: FetchOptions) { +const _$fetch = createFetch({ fetch: globalFetch }) + +export const $fetch = function $fetch(path: string, options?: FetchOptions) { return _$fetch(url(path), options) } as (typeof globalThis)['$fetch'] diff --git a/src/e2e/setup/bun.ts b/src/e2e/setup/bun.ts index c9e7e14be..3d9886d41 100644 --- a/src/e2e/setup/bun.ts +++ b/src/e2e/setup/bun.ts @@ -1,7 +1,6 @@ import type { TestHooks } from '../types' export default async function setupBun(hooks: TestHooks) { - // @ts-expect-error we do not want bun types present in global context const bunTest = await import('bun:test') // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/environments/vitest/types.ts b/src/environments/vitest/types.ts index 387b603db..3d81b228d 100644 --- a/src/environments/vitest/types.ts +++ b/src/environments/vitest/types.ts @@ -1,10 +1,10 @@ -import type { App } from 'h3' +import type { H3 } from 'h3-next' import type { $Fetch } from 'nitropack' import type { JSDOMOptions, HappyDOMOptions } from 'vitest/node' export type NuxtBuiltinEnvironment = 'happy-dom' | 'jsdom' export interface NuxtWindow extends Window { - __app: App + __app: H3 __registry: Set __NUXT_VITEST_ENVIRONMENT__?: boolean __NUXT__: Record diff --git a/src/runtime-utils/mock.ts b/src/runtime-utils/mock.ts index 6a146f8f6..f74214c88 100644 --- a/src/runtime-utils/mock.ts +++ b/src/runtime-utils/mock.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ -import { defineEventHandler } from 'h3' -import type { App, EventHandler, HTTPMethod } from 'h3' +import { defineEventHandler } from 'h3-next/generic' +import type { H3, EventHandler, HTTPMethod } from 'h3-next' import type { ComponentInjectOptions, ComponentOptionsMixin, @@ -44,7 +44,7 @@ const endpointRegistry: Record { - const latestHandler = [...endpointRegistry[url] || []].reverse().find(config => config.method ? event.method === config.method : true) - if (!latestHandler) return - - const result = await latestHandler.handler(event) - - if (!latestHandler.once) return result - - const index = endpointRegistry[url]?.indexOf(latestHandler) - if (index === undefined || index === -1) return result - - endpointRegistry[url]?.splice(index, 1) - if (endpointRegistry[url]?.length === 0) { - // @ts-expect-error private property - window.__registry.delete(url) - } + // @ts-expect-error private property + window.__registry.add(url) - return result - }), { - match(_, event) { - return endpointRegistry[url]?.some(config => config.method ? event?.method === config.method : true) ?? false - }, - }) - } + // @ts-expect-error private property + app._registered + ||= registerGlobalHandler(app) return () => { endpointRegistry[url]?.splice(endpointRegistry[url].indexOf(config), 1) @@ -262,3 +237,33 @@ export function mockComponent(_path: string, _component: unknown): void { 'mockComponent() is a macro and it did not get transpiled. This may be an internal bug of @nuxt/test-utils.', ) } + +function registerGlobalHandler(app: H3) { + app.use(async (event) => { + const url = event.url.pathname.replace(/^\/_/, '') + const latestHandler = [...endpointRegistry[url] || []].reverse().find(config => config.method ? event.method === config.method : true) + if (!latestHandler) return + + const result = await latestHandler.handler(event) + + if (!latestHandler.once) return result + + const index = endpointRegistry[url]?.indexOf(latestHandler) + if (index === undefined || index === -1) return result + + endpointRegistry[url]?.splice(index, 1) + if (endpointRegistry[url]?.length === 0) { + // @ts-expect-error private property + window.__registry.delete(url) + } + + return result + }, { + match: (event) => { + const url = event.url.pathname.replace(/^\/_/, '') + return endpointRegistry[url]?.some(config => config.method ? event?.method === config.method : true) ?? false + }, + }) + + return true +} diff --git a/src/runtime/shared/environment.ts b/src/runtime/shared/environment.ts index 9dc1ff9c4..ac451911f 100644 --- a/src/runtime/shared/environment.ts +++ b/src/runtime/shared/environment.ts @@ -1,8 +1,7 @@ import { createFetch } from 'ofetch' import { joinURL } from 'ufo' -import { createApp, defineEventHandler, toNodeListener } from 'h3' +import { H3, defineEventHandler } from 'h3-next/generic' import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3' -import { fetchNodeRequestHandler } from 'node-mock-http' import type { NuxtWindow } from '../../vitest-environment' import type { NuxtEnvironmentOptions } from '../../config' @@ -45,7 +44,7 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N app.id = rootId win.document.body.appendChild(app) - const h3App = createApp() + const h3App = new H3() if (!win.fetch || !('Request' in win)) { await import('node-fetch-native/polyfill') @@ -64,8 +63,6 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N } } - const nodeHandler = toNodeListener(h3App) - const registry = new Set() const _fetch = fetch @@ -89,11 +86,10 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N const base = url.split('?')[0]! if (registry.has(base) || registry.has(url)) { - url = '/_' + url + return h3App.fetch(new Request('/_' + url, init)) } if (url.startsWith('/')) { - const response = await fetchNodeRequestHandler(nodeHandler, url, init) - return normalizeFetchResponse(response) + return new Response('Not Found', { status: 404, statusText: 'Not Found' }) } return _fetch(input, _init) } @@ -144,39 +140,3 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N console.info = consoleInfo } } - -/** utils from nitro */ - -function normalizeFetchResponse(response: Response) { - if (!response.headers.has('set-cookie')) { - return response - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: normalizeCookieHeaders(response.headers), - }) -} - -function normalizeCookieHeader(header: number | string | string[] = '') { - return splitCookiesString(joinHeaders(header)) -} - -function normalizeCookieHeaders(headers: Headers) { - const outgoingHeaders = new Headers() - for (const [name, header] of headers) { - if (name === 'set-cookie') { - for (const cookie of normalizeCookieHeader(header)) { - outgoingHeaders.append('set-cookie', cookie) - } - } - else { - outgoingHeaders.set(name, joinHeaders(header)) - } - } - return outgoingHeaders -} - -function joinHeaders(value: number | string | string[]) { - return Array.isArray(value) ? value.join(', ') : String(value) -}