feat(kit,nuxt): allow multiple nuxts to run in one process (#30510)

This commit is contained in:
Julien Huang 2025-02-11 16:38:55 +01:00 committed by Daniel Roe
parent d47b830d3f
commit 2fa17462da
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
6 changed files with 109 additions and 34 deletions

View File

@ -1,9 +1,22 @@
import { getContext } from 'unctx'
import { AsyncLocalStorage } from 'node:async_hooks'
import { createContext, getContext } from 'unctx'
import type { Nuxt } from '@nuxt/schema'
/** Direct access to the Nuxt context - see https://github.com/unjs/unctx. */
/**
* Direct access to the Nuxt global context - see https://github.com/unjs/unctx.
* @deprecated Use `getNuxtCtx` instead
*/
export const nuxtCtx = getContext<Nuxt>('nuxt')
/** async local storage for the name of the current nuxt instance */
const asyncNuxtStorage = createContext<Nuxt>({
asyncContext: true,
AsyncLocalStorage,
})
/** Direct access to the Nuxt context with asyncLocalStorage - see https://github.com/unjs/unctx. */
export const getNuxtCtx = () => asyncNuxtStorage.tryUse()
// TODO: Use use/tryUse from unctx. https://github.com/unjs/unctx/issues/6
/**
@ -16,7 +29,7 @@ export const nuxtCtx = getContext<Nuxt>('nuxt')
* ```
*/
export function useNuxt (): Nuxt {
const instance = nuxtCtx.tryUse()
const instance = asyncNuxtStorage.tryUse() || nuxtCtx.tryUse()
if (!instance) {
throw new Error('Nuxt instance is unavailable!')
}
@ -36,5 +49,9 @@ export function useNuxt (): Nuxt {
* ```
*/
export function tryUseNuxt (): Nuxt | null {
return nuxtCtx.tryUse()
return asyncNuxtStorage.tryUse() || nuxtCtx.tryUse()
}
export function runWithNuxtContext<T extends (...args: any[]) => any> (nuxt: Nuxt, fn: T) {
return asyncNuxtStorage.call(nuxt, fn) as ReturnType<T>
}

View File

@ -18,7 +18,7 @@ export type { ExtendConfigOptions, ExtendViteConfigOptions, ExtendWebpackConfigO
export { assertNuxtCompatibility, checkNuxtCompatibility, getNuxtVersion, hasNuxtCompatibility, isNuxtMajorVersion, normalizeSemanticVersion, isNuxt2, isNuxt3 } from './compatibility'
export { addComponent, addComponentsDir } from './components'
export type { AddComponentOptions } from './components'
export { nuxtCtx, tryUseNuxt, useNuxt } from './context'
export { getNuxtCtx, runWithNuxtContext, tryUseNuxt, useNuxt, nuxtCtx } from './context'
export { createIsIgnored, isIgnored, resolveIgnorePatterns } from './ignore'
export { addLayout } from './layout'
export { addRouteMiddleware, extendPages, extendRouteRules } from './pages'

View File

@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url'
import { readPackageJSON, resolvePackageJSON } from 'pkg-types'
import type { Nuxt } from '@nuxt/schema'
import { importModule, tryImportModule } from '../internal/esm'
import { runWithNuxtContext } from '../context'
import type { LoadNuxtConfigOptions } from './config'
export interface LoadNuxtOptions extends LoadNuxtConfigOptions {
@ -76,10 +77,10 @@ export async function buildNuxt (nuxt: Nuxt): Promise<any> {
// Nuxt 3
if (nuxt.options._majorVersion === 3) {
const { build } = await tryImportModule<typeof import('nuxt')>('nuxt-nightly', { paths: rootDir }) || await tryImportModule<typeof import('nuxt')>('nuxt3', { paths: rootDir }) || await importModule<typeof import('nuxt')>('nuxt', { paths: rootDir })
return build(nuxt)
return runWithNuxtContext(nuxt, () => build(nuxt))
}
// Nuxt 2
const { build } = await tryImportModule<{ build: any }>('nuxt-edge', { paths: rootDir }) || await importModule<{ build: any }>('nuxt', { paths: rootDir })
return build(nuxt)
return runWithNuxtContext(nuxt, () => build(nuxt))
}

View File

@ -1,11 +1,12 @@
import { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { AsyncLocalStorage } from 'node:async_hooks'
import { join, normalize, relative, resolve } from 'pathe'
import { createDebugger, createHooks } from 'hookable'
import ignore from 'ignore'
import type { LoadNuxtOptions } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addPluginTemplate, addRouteMiddleware, addServerPlugin, addTypeTemplate, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, nuxtCtx, resolveAlias, resolveFiles, resolveIgnorePatterns, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addPluginTemplate, addRouteMiddleware, addServerPlugin, addTypeTemplate, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, nuxtCtx, resolveAlias, resolveFiles, resolveIgnorePatterns, resolvePath, runWithNuxtContext, tryResolveModule, useNitro } from '@nuxt/kit'
import type { Nuxt, NuxtHooks, NuxtModule, NuxtOptions } from 'nuxt/schema'
import type { PackageJson } from 'pkg-types'
import { readPackageJSON } from 'pkg-types'
@ -52,17 +53,24 @@ import { VirtualFSPlugin } from './plugins/virtual'
export function createNuxt (options: NuxtOptions): Nuxt {
const hooks = createHooks<NuxtHooks>()
const { callHook, callHookParallel, callHookWith } = hooks
hooks.callHook = (...args) => runWithNuxtContext(nuxt, () => callHook(...args))
hooks.callHookParallel = (...args) => runWithNuxtContext(nuxt, () => callHookParallel(...args))
hooks.callHookWith = (...args) => runWithNuxtContext(nuxt, () => callHookWith(...args))
const nuxt: Nuxt = {
__name: randomUUID(),
_version: version,
_asyncLocalStorageModule: options.experimental.debugModuleMutation ? new AsyncLocalStorage() : undefined,
hooks,
callHook: hooks.callHook,
addHooks: hooks.addHooks,
hook: hooks.hook,
ready: () => initNuxt(nuxt),
ready: () => runWithNuxtContext(nuxt, () => initNuxt(nuxt)),
close: () => hooks.callHook('close', nuxt),
vfs: {},
apps: {},
runWithContext: fn => runWithNuxtContext(nuxt, fn),
options,
}
@ -114,6 +122,14 @@ export function createNuxt (options: NuxtOptions): Nuxt {
})
}
if (!nuxtCtx.tryUse()) {
// backward compatibility with 3.x
nuxtCtx.set(nuxt)
nuxt.hook('close', () => {
nuxtCtx.unset()
})
}
hooks.hookOnce('close', () => { hooks.removeAllHooks() })
return nuxt
@ -221,11 +237,6 @@ async function initNuxt (nuxt: Nuxt) {
}
}
})
// Set nuxt instance for useNuxt
nuxtCtx.set(nuxt)
nuxt.hook('close', () => nuxtCtx.unset())
const coreTypePackages = nuxt.options.typescript.hoist || []
// Disable environment types entirely if `typescript.builder` is false
@ -860,27 +871,29 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> {
const nuxt = createNuxt(options)
if (nuxt.options.dev && !nuxt.options.test) {
nuxt.hooks.hookOnce('build:done', () => {
for (const dep of keyDependencies) {
checkDependencyVersion(dep, nuxt._version)
.catch(e => logger.warn(`Problem checking \`${dep}\` version.`, e))
}
})
}
nuxt.runWithContext(() => {
if (nuxt.options.dev && !nuxt.options.test) {
nuxt.hooks.hookOnce('build:done', () => {
for (const dep of keyDependencies) {
checkDependencyVersion(dep, nuxt._version)
.catch(e => logger.warn(`Problem checking \`${dep}\` version.`, e))
}
})
}
// We register hooks layer-by-layer so any overrides need to be registered separately
if (opts.overrides?.hooks) {
nuxt.hooks.addHooks(opts.overrides.hooks)
}
// We register hooks layer-by-layer so any overrides need to be registered separately
if (opts.overrides?.hooks) {
nuxt.hooks.addHooks(opts.overrides.hooks)
}
if (
nuxt.options.debug
&& nuxt.options.debug.hooks
&& (nuxt.options.debug.hooks === true || nuxt.options.debug.hooks.server)
) {
createDebugger(nuxt.hooks, { tag: 'nuxt' })
}
if (
nuxt.options.debug
&& nuxt.options.debug.hooks
&& (nuxt.options.debug.hooks === true || nuxt.options.debug.hooks.server)
) {
createDebugger(nuxt.hooks, { tag: 'nuxt' })
}
})
if (opts.ready !== false) {
await nuxt.ready()

View File

@ -1,7 +1,8 @@
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { normalize } from 'pathe'
import { withoutTrailingSlash } from 'ufo'
import { logger, tryUseNuxt, useNuxt } from '@nuxt/kit'
import { loadNuxt } from '../src'
const repoRoot = withoutTrailingSlash(normalize(fileURLToPath(new URL('../../../', import.meta.url))))
@ -12,6 +13,7 @@ vi.stubGlobal('console', {
warn: vi.fn(console.warn),
})
const loggerWarn = vi.spyOn(logger, 'warn')
vi.mock('pkg-types', async (og) => {
const originalPkgTypes = (await og<typeof import('pkg-types')>())
return {
@ -20,6 +22,9 @@ vi.mock('pkg-types', async (og) => {
}
})
beforeEach(() => {
loggerWarn.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
@ -41,4 +46,41 @@ describe('loadNuxt', () => {
await nuxt.close()
expect(hookRan).toBe(true)
})
it('load multiple nuxt', async () => {
await Promise.all([
loadNuxt({
cwd: repoRoot,
}),
loadNuxt({
cwd: repoRoot,
}),
])
expect(loggerWarn).not.toHaveBeenCalled()
})
it('expect hooks to get the correct context outside of initNuxt', async () => {
const nuxt = await loadNuxt({
cwd: repoRoot,
})
// @ts-expect-error - random hook
nuxt.hook('test', () => {
expect(useNuxt().__name).toBe(nuxt.__name)
})
expect(tryUseNuxt()?.__name).not.toBe(nuxt.__name)
// second nuxt context
const second = await loadNuxt({
cwd: repoRoot,
})
expect(second.__name).not.toBe(nuxt.__name)
expect(tryUseNuxt()?.__name).not.toBe(nuxt.__name)
// @ts-expect-error - random hook
await nuxt.callHook('test')
expect(loggerWarn).not.toHaveBeenCalled()
})
})

View File

@ -83,6 +83,7 @@ export interface NuxtApp {
export interface Nuxt {
// Private fields.
__name: string
_version: string
_ignore?: Ignore
_dependencies?: Set<string>
@ -96,6 +97,7 @@ export interface Nuxt {
hook: Nuxt['hooks']['hook']
callHook: Nuxt['hooks']['callHook']
addHooks: Nuxt['hooks']['addHooks']
runWithContext: <T extends (...args: any[]) => any>(fn: T) => ReturnType<T>
ready: () => Promise<void>
close: () => Promise<void>