From 2fa17462da9426bead2c22e5b725a6598cf072a8 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Tue, 11 Feb 2025 16:38:55 +0100 Subject: [PATCH] feat(kit,nuxt): allow multiple nuxts to run in one process (#30510) --- packages/kit/src/context.ts | 25 +++++++++-- packages/kit/src/index.ts | 2 +- packages/kit/src/loader/nuxt.ts | 5 ++- packages/nuxt/src/core/nuxt.ts | 65 +++++++++++++++++----------- packages/nuxt/test/load-nuxt.test.ts | 44 ++++++++++++++++++- packages/schema/src/types/nuxt.ts | 2 + 6 files changed, 109 insertions(+), 34 deletions(-) diff --git a/packages/kit/src/context.ts b/packages/kit/src/context.ts index d132b81c6c..6293bfe9fc 100644 --- a/packages/kit/src/context.ts +++ b/packages/kit/src/context.ts @@ -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') +/** async local storage for the name of the current nuxt instance */ +const asyncNuxtStorage = createContext({ + 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') * ``` */ 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 any> (nuxt: Nuxt, fn: T) { + return asyncNuxtStorage.call(nuxt, fn) as ReturnType } diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index a32f5e7412..b058e71585 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -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' diff --git a/packages/kit/src/loader/nuxt.ts b/packages/kit/src/loader/nuxt.ts index efcd315ae0..adf5446fa1 100644 --- a/packages/kit/src/loader/nuxt.ts +++ b/packages/kit/src/loader/nuxt.ts @@ -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 { // Nuxt 3 if (nuxt.options._majorVersion === 3) { const { build } = await tryImportModule('nuxt-nightly', { paths: rootDir }) || await tryImportModule('nuxt3', { paths: rootDir }) || await importModule('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)) } diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 63f933b8da..34c67d88d3 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -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() + 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 { 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() diff --git a/packages/nuxt/test/load-nuxt.test.ts b/packages/nuxt/test/load-nuxt.test.ts index 96fd8723cf..e17065c035 100644 --- a/packages/nuxt/test/load-nuxt.test.ts +++ b/packages/nuxt/test/load-nuxt.test.ts @@ -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()) 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() + }) }) diff --git a/packages/schema/src/types/nuxt.ts b/packages/schema/src/types/nuxt.ts index 5e50e7adaf..ed95dd6851 100644 --- a/packages/schema/src/types/nuxt.ts +++ b/packages/schema/src/types/nuxt.ts @@ -83,6 +83,7 @@ export interface NuxtApp { export interface Nuxt { // Private fields. + __name: string _version: string _ignore?: Ignore _dependencies?: Set @@ -96,6 +97,7 @@ export interface Nuxt { hook: Nuxt['hooks']['hook'] callHook: Nuxt['hooks']['callHook'] addHooks: Nuxt['hooks']['addHooks'] + runWithContext: any>(fn: T) => ReturnType ready: () => Promise close: () => Promise