diff --git a/knip.json b/knip.json index 46b548390..af7e03919 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ }, "packages/nuxt-cli/playground": { "ignoreDependencies": [ + "@nuxt/test-utils", "nuxt" ] }, @@ -21,6 +22,7 @@ "clipboardy", "consola", "defu", + "exsolve", "fuse.js", "giget", "h3", diff --git a/packages/nuxi/bin/nuxi.mjs b/packages/nuxi/bin/nuxi.mjs index 7a5eaeffa..08683cf73 100755 --- a/packages/nuxi/bin/nuxi.mjs +++ b/packages/nuxi/bin/nuxi.mjs @@ -6,6 +6,7 @@ import { runMain } from '../dist/index.mjs' globalThis.__nuxt_cli__ = { startTime: Date.now(), entry: fileURLToPath(import.meta.url), + devEntry: fileURLToPath(new URL('../dist/dev/index.mjs', import.meta.url)), } runMain() diff --git a/packages/nuxi/build.config.ts b/packages/nuxi/build.config.ts index 031d108cd..29d8c2afc 100644 --- a/packages/nuxi/build.config.ts +++ b/packages/nuxi/build.config.ts @@ -27,7 +27,10 @@ export default defineBuildConfig({ exportConditions: ['production', 'node'], }, }, - entries: ['src/index'], + entries: [ + 'src/index', + 'src/dev/index.ts', + ], externals: [ '@nuxt/test-utils', 'fsevents', diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index f543e6c3f..865bda94d 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -50,6 +50,7 @@ "clipboardy": "^4.0.0", "consola": "^3.4.2", "defu": "^6.1.4", + "exsolve": "^1.0.5", "fuse.js": "^7.1.0", "giget": "^2.0.0", "h3": "^1.15.3", diff --git a/packages/nuxi/src/commands/build.ts b/packages/nuxi/src/commands/build.ts index 40136e365..b6c2469bf 100644 --- a/packages/nuxi/src/commands/build.ts +++ b/packages/nuxi/src/commands/build.ts @@ -37,7 +37,7 @@ export default defineCommand({ const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) - await showVersions(cwd) + showVersions(cwd) const kit = await loadKit(cwd) diff --git a/packages/nuxi/src/commands/dev-child.ts b/packages/nuxi/src/commands/dev-child.ts index d868beb43..c3557d145 100644 --- a/packages/nuxi/src/commands/dev-child.ts +++ b/packages/nuxi/src/commands/dev-child.ts @@ -1,15 +1,8 @@ -import type { NuxtDevContext, NuxtDevIPCMessage } from '../utils/dev' - import process from 'node:process' - import { defineCommand } from 'citty' -import defu from 'defu' import { resolve } from 'pathe' import { isTest } from 'std-env' -import { _getDevServerDefaults, _getDevServerOverrides, createNuxtDevServer } from '../utils/dev' -import { overrideEnv } from '../utils/env' -import { logger } from '../utils/logger' import { cwdArgs, dotEnvArgs, envNameArgs, legacyRootDirArgs, logLevelArgs } from './_shared' export default defineCommand({ @@ -23,80 +16,20 @@ export default defineCommand({ ...envNameArgs, ...dotEnvArgs, ...legacyRootDirArgs, + clear: { + type: 'boolean', + description: 'Clear console on restart', + negativeDescription: 'Disable clear console on restart', + }, }, async run(ctx) { if (!process.send && !isTest) { - logger.warn('`nuxi _dev` is an internal command and should not be used directly. Please use `nuxi dev` instead.') + console.warn('`nuxi _dev` is an internal command and should not be used directly. Please use `nuxi dev` instead.') } - // Prepare - overrideEnv('development') const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) - // Get dev context info - const devContext: NuxtDevContext = JSON.parse(process.env.__NUXT_DEV__ || 'null') || {} - - // IPC Hooks - function sendIPCMessage(message: T) { - if (process.send) { - process.send(message) - } - else { - logger.info( - 'Dev server event:', - Object.entries(message) - .map(e => `${e[0]}=${JSON.stringify(e[1])}`) - .join(' '), - ) - } - } - - process.once('unhandledRejection', (reason) => { - sendIPCMessage({ type: 'nuxt:internal:dev:rejection', message: reason instanceof Error ? reason.toString() : 'Unhandled Rejection' }) - process.exit() - }) - - const devServerOverrides = _getDevServerOverrides({ - public: devContext.public, - }) - - const devServerDefaults = _getDevServerDefaults({ - hostname: devContext.hostname, - https: devContext.proxy?.https, - }, devContext.publicURLs) - - // Init Nuxt dev - const nuxtDev = await createNuxtDevServer({ - cwd, - overrides: defu(ctx.data?.overrides, devServerOverrides), - defaults: devServerDefaults, - logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose', - clear: !!ctx.args.clear, - dotenv: { cwd, fileName: ctx.args.dotenv }, - envName: ctx.args.envName, - port: process.env._PORT ?? undefined, - devContext, - }) - - nuxtDev.on('loading:error', (_error) => { - sendIPCMessage({ type: 'nuxt:internal:dev:loading:error', error: { - message: _error.message, - stack: _error.stack, - name: _error.name, - code: _error.code, - } }) - }) - nuxtDev.on('loading', (message) => { - sendIPCMessage({ type: 'nuxt:internal:dev:loading', message }) - }) - nuxtDev.on('restart', () => { - sendIPCMessage({ type: 'nuxt:internal:dev:restart' }) - }) - nuxtDev.on('ready', (payload) => { - sendIPCMessage({ type: 'nuxt:internal:dev:ready', port: payload.port }) - }) - - // Init server - await nuxtDev.init() + const { initialize } = await import('../dev') + await initialize({ cwd, args: ctx.args }, ctx) }, }) diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index fec78c130..da572fbac 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -3,28 +3,28 @@ import type { ParsedArgs } from 'citty' import type { HTTPSOptions, ListenOptions } from 'listhen' import type { ChildProcess } from 'node:child_process' import type { IncomingMessage, ServerResponse } from 'node:http' -import type { NuxtDevContext, NuxtDevIPCMessage } from '../utils/dev' +import type { NuxtDevContext, NuxtDevIPCMessage } from '../dev/utils' import { fork } from 'node:child_process' import process from 'node:process' import { defineCommand } from 'citty' -import defu from 'defu' -import { createJiti } from 'jiti' +import { createProxyServer } from 'httpxy' +import { listen } from 'listhen' import { getArgs as getListhenArgs, parseArgs as parseListhenArgs } from 'listhen/cli' import { resolve } from 'pathe' import { satisfies } from 'semver' - import { isBun, isTest } from 'std-env' + +import { initialize } from '../dev' +import { renderError } from '../dev/error' import { showVersions } from '../utils/banner' -import { _getDevServerDefaults, _getDevServerOverrides } from '../utils/dev' import { overrideEnv } from '../utils/env' -import { renderError } from '../utils/error' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { cwdArgs, dotEnvArgs, envNameArgs, legacyRootDirArgs, logLevelArgs } from './_shared' -let startTime: number | undefined = Date.now() +const startTime: number | undefined = Date.now() const forkSupported = !isTest && (!isBun || isBunForkSupported()) const listhenArgs = getListhenArgs() @@ -87,7 +87,7 @@ const command = defineCommand({ // Prepare overrideEnv('development') const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) - await showVersions(cwd) + showVersions(cwd) // Load Nuxt Config const { loadNuxtConfig } = await loadKit(cwd) @@ -102,59 +102,65 @@ const command = defineCommand({ }, }) - // Start Proxy Listener - const listenOptions = _resolveListenOptions(nuxtOptions, ctx.args) + const listenOptions = resolveListenOptions(nuxtOptions, ctx.args) + if (!ctx.args.fork) { + // Directly start Nuxt dev + const { listener, close } = await initialize({ + cwd, + args: ctx.args, + hostname: listenOptions.hostname, + public: listenOptions.public, + publicURLs: undefined, + proxy: { + https: listenOptions.https, + }, + }, { data: ctx.data }, listenOptions) - if (ctx.args.fork) { - // Fork Nuxt dev process - const devProxy = await _createDevProxy(nuxtOptions, listenOptions) - const subprocess = await _startSubprocess(devProxy, ctx.rawArgs, listenOptions) return { - listener: devProxy?.listener, + listener, async close() { - await devProxy?.listener.close() - subprocess?.kill(0) + await close() + await listener.close() }, } } - else { - // Directly start Nuxt dev - const { createNuxtDevServer } = await import('../utils/dev') - const devServerOverrides = _getDevServerOverrides({ - public: listenOptions.public, - }) + // Start proxy Listener + const devProxy = await createDevProxy(nuxtOptions, listenOptions) - const devServerDefaults = _getDevServerDefaults({ - hostname: listenOptions.hostname, - https: listenOptions.https, - }) + const urls = await devProxy.listener.getURLs() + // run initially in in no-fork mode + const { onRestart, onReady, close } = await initialize({ + cwd, + args: ctx.args, + hostname: listenOptions.hostname, + public: listenOptions.public, + publicURLs: urls.map(r => r.url), + proxy: { + url: devProxy.listener.url, + urls, + https: devProxy.listener.https, + }, + }) - const devServer = await createNuxtDevServer( - { - cwd, - overrides: defu(ctx.data?.overrides, devServerOverrides), - defaults: devServerDefaults, - logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose', - clear: ctx.args.clear, - dotenv: { - cwd, - fileName: ctx.args.dotenv, - }, - envName: ctx.args.envName, - loadingTemplate: nuxtOptions.devServer.loadingTemplate, - devContext: {}, - }, - listenOptions, - ) - await devServer.init() - return { - listener: devServer?.listener, - async close() { - await devServer.listener.close() - await devServer.close() - }, - } + onReady(port => devProxy.setAddress(`http://127.0.0.1:${port}`)) + + // ... then fall back to pre-warmed fork if a hard restart is required + const fork = startSubprocess(cwd, ctx.args, ctx.rawArgs, listenOptions) + onRestart(async (devServer) => { + await devServer.close() + const subprocess = await fork + await subprocess.initialize(devProxy) + }) + + return { + listener: devProxy.listener, + async close() { + await close() + const subprocess = await fork + subprocess.kill(0) + await devProxy.listener.close() + }, } }, }) @@ -168,27 +174,18 @@ type ArgsT = Exclude< undefined | ((...args: unknown[]) => unknown) > -type DevProxy = Awaited> +type DevProxy = Awaited> -async function _createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial) { - const jiti = createJiti(nuxtOptions.rootDir) +async function createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial) { let loadingMessage = 'Nuxt dev server is starting...' let error: Error | undefined + let address: string | undefined + let loadingTemplate = nuxtOptions.devServer.loadingTemplate - for (const url of nuxtOptions.modulesDir) { - // @ts-expect-error this is for backwards compatibility - if (loadingTemplate) { - break - } - loadingTemplate = await jiti.import<{ loading: () => string }>('@nuxt/ui-templates', { parentURL: url }).then(r => r.loading) - } - const { createProxyServer } = await import('httpxy') const proxy = createProxyServer({}) - let address: string | undefined - - const handler = (req: IncomingMessage, res: ServerResponse) => { + const listener = await listen((req: IncomingMessage, res: ServerResponse) => { if (error) { renderError(req, res, error) return @@ -196,28 +193,43 @@ async function _createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial< if (!address) { res.statusCode = 503 res.setHeader('Content-Type', 'text/html') - res.end(loadingTemplate({ loading: loadingMessage })) - return + if (loadingTemplate) { + res.end(loadingTemplate({ loading: loadingMessage })) + return + } + // older versions of Nuxt did not have the loading template defined in the schema + + async function resolveLoadingMessage() { + const { createJiti } = await import('jiti') + const jiti = createJiti(nuxtOptions.rootDir) + for (const url of nuxtOptions.modulesDir) { + const r = await jiti.import<{ loading: (opts?: { loading?: string }) => string }>('@nuxt/ui-templates', { + parentURL: url, + try: true, + }) + if (r) { + loadingTemplate = r.loading + res.end(r.loading({ loading: loadingMessage })) + break + } + } + } + return resolveLoadingMessage() } - return proxy.web(req, res, { target: address }) - } + proxy.web(req, res, { target: address }) + }, listenOptions) - const wsHandler = (req: IncomingMessage, socket: any, head: any) => { + listener.server.on('upgrade', (req, socket, head) => { if (!address) { socket.destroy() return } + // @ts-expect-error TODO: fix socket type in httpxy return proxy.ws(req, socket, { target: address }, head) - } - - const { listen } = await import('listhen') - const listener = await listen(handler, listenOptions) - listener.server.on('upgrade', wsHandler) + }) return { listener, - handler, - wsHandler, setAddress: (_addr: string | undefined) => { address = _addr }, @@ -233,9 +245,10 @@ async function _createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial< } } -async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArgs: Partial) { +async function startSubprocess(cwd: string, args: { logLevel: string, clear: boolean, dotenv: string, envName: string }, rawArgs: string[], listenOptions: Partial) { let childProc: ChildProcess | undefined - + let devProxy: DevProxy + let ready: Promise | undefined const kill = (signal: NodeJS.Signals | number) => { if (childProc) { childProc.kill(signal) @@ -243,8 +256,29 @@ async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArg } } - const restart = async () => { - devProxy.clearError() + async function initialize(proxy: DevProxy) { + devProxy = proxy + const urls = await devProxy.listener.getURLs() + await ready + childProc!.send({ + type: 'nuxt:internal:dev:context', + context: { + cwd, + args, + hostname: listenOptions.hostname, + public: listenOptions.public, + publicURLs: urls.map(r => r.url), + proxy: { + url: devProxy.listener.url, + urls, + https: devProxy.listener.https, + }, + } satisfies NuxtDevContext, + }) + } + + async function restart() { + devProxy?.clearError() // Kill previous process with restart signal (not supported on Windows) if (process.platform === 'win32') { kill('SIGTERM') @@ -253,23 +287,11 @@ async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArg kill('SIGHUP') } // Start new process - childProc = fork(globalThis.__nuxt_cli__!.entry!, ['_dev', ...rawArgs], { - execArgv: [ - '--enable-source-maps', - process.argv.find((a: string) => a.includes('--inspect')), - ].filter(Boolean) as string[], + childProc = fork(globalThis.__nuxt_cli__.devEntry!, rawArgs, { + execArgv: ['--enable-source-maps', process.argv.find((a: string) => a.includes('--inspect'))].filter(Boolean) as string[], env: { ...process.env, - __NUXT_DEV__: JSON.stringify({ - hostname: listenArgs.hostname, - public: listenArgs.public, - publicURLs: await devProxy.listener.getURLs().then(r => r.map(r => r.url)), - proxy: { - url: devProxy.listener.url, - urls: await devProxy.listener.getURLs(), - https: devProxy.listener.https, - }, - } satisfies NuxtDevContext), + __NUXT__FORK: 'true', }, }) @@ -281,30 +303,35 @@ async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArg }) // Listen for IPC messages - childProc.on('message', (message: NuxtDevIPCMessage) => { - if (message.type === 'nuxt:internal:dev:ready') { - devProxy.setAddress(`http://127.0.0.1:${message.port}`) - if (startTime) { - logger.debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) - startTime = undefined + ready = new Promise((resolve, reject) => { + childProc!.on('error', reject) + childProc!.on('message', (message: NuxtDevIPCMessage) => { + if (message.type === 'nuxt:internal:dev:fork-ready') { + resolve() } - } - else if (message.type === 'nuxt:internal:dev:loading') { - devProxy.setAddress(undefined) - devProxy.setLoadingMessage(message.message) - devProxy.clearError() - } - else if (message.type === 'nuxt:internal:dev:loading:error') { - devProxy.setAddress(undefined) - devProxy.setError(message.error) - } - else if (message.type === 'nuxt:internal:dev:restart') { - restart() - } - else if (message.type === 'nuxt:internal:dev:rejection') { - logger.info(`Restarting Nuxt due to error: \`${message.message}\``) - restart() - } + else if (message.type === 'nuxt:internal:dev:ready') { + devProxy.setAddress(`http://127.0.0.1:${message.port}`) + if (startTime) { + logger.debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) + } + } + else if (message.type === 'nuxt:internal:dev:loading') { + devProxy.setAddress(undefined) + devProxy.setLoadingMessage(message.message) + devProxy.clearError() + } + else if (message.type === 'nuxt:internal:dev:loading:error') { + devProxy.setAddress(undefined) + devProxy.setError(message.error) + } + else if (message.type === 'nuxt:internal:dev:restart') { + restart() + } + else if (message.type === 'nuxt:internal:dev:rejection') { + logger.info(`Restarting Nuxt due to error: \`${message.message}\``) + restart() + } + }) }) } @@ -323,12 +350,13 @@ async function _startSubprocess(devProxy: DevProxy, rawArgs: string[], listenArg await restart() return { + initialize, restart, kill, } } -function _resolveListenOptions( +function resolveListenOptions( nuxtOptions: NuxtOptions, args: ParsedArgs, ): Partial { diff --git a/packages/nuxi/src/commands/info.ts b/packages/nuxi/src/commands/info.ts index 5ce8b7994..86cfb6d12 100644 --- a/packages/nuxi/src/commands/info.ts +++ b/packages/nuxi/src/commands/info.ts @@ -40,7 +40,7 @@ export default defineCommand({ const { dependencies = {}, devDependencies = {} } = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) // Utils to query a dependency version - const nuxtPath = await tryResolveNuxt(cwd) + const nuxtPath = tryResolveNuxt(cwd) async function getDepVersion(name: string) { for (const url of [cwd, nuxtPath]) { if (!url) { diff --git a/packages/nuxi/src/commands/typecheck.ts b/packages/nuxi/src/commands/typecheck.ts index a435dbbe0..a13c62a05 100644 --- a/packages/nuxi/src/commands/typecheck.ts +++ b/packages/nuxi/src/commands/typecheck.ts @@ -2,7 +2,7 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' import { defineCommand } from 'citty' -import { createJiti } from 'jiti' +import { resolveModulePath } from 'exsolve' import { resolve } from 'pathe' import { isBun } from 'std-env' import { x } from 'tinyexec' @@ -41,12 +41,10 @@ export default defineCommand({ await buildNuxt(nuxt) await nuxt.close() - const jiti = createJiti(cwd) - // Prefer local install if possible const [resolvedTypeScript, resolvedVueTsc] = await Promise.all([ - jiti.esmResolve('typescript', { try: true }), - jiti.esmResolve('vue-tsc/bin/vue-tsc.js', { try: true }), + resolveModulePath('typescript', { try: true }), + resolveModulePath('vue-tsc/bin/vue-tsc.js', { try: true }), ]) if (resolvedTypeScript && resolvedVueTsc) { await x(fileURLToPath(resolvedVueTsc), ['--noEmit'], { diff --git a/packages/nuxi/src/utils/error.ts b/packages/nuxi/src/dev/error.ts similarity index 100% rename from packages/nuxi/src/utils/error.ts rename to packages/nuxi/src/dev/error.ts diff --git a/packages/nuxi/src/dev/index.ts b/packages/nuxi/src/dev/index.ts new file mode 100644 index 000000000..43456bace --- /dev/null +++ b/packages/nuxi/src/dev/index.ts @@ -0,0 +1,121 @@ +import type { NuxtConfig } from '@nuxt/schema' +import type { ListenOptions } from 'listhen' +import type { NuxtDevContext, NuxtDevIPCMessage, NuxtDevServer, NuxtParentIPCMessage } from './utils' + +import process from 'node:process' +import defu from 'defu' +import { createNuxtDevServer, resolveDevServerDefaults, resolveDevServerOverrides } from './utils' + +const start = Date.now() + +// Prepare +process.env.NODE_ENV = 'development' + +interface InitializeOptions { + data?: { + overrides?: NuxtConfig + } +} + +// IPC Hooks +class IPC { + enabled = !!process.send && !process.title.includes('vitest') && process.env.__NUXT__FORK + constructor() { + process.once('unhandledRejection', (reason) => { + this.send({ type: 'nuxt:internal:dev:rejection', message: reason instanceof Error ? reason.toString() : 'Unhandled Rejection' }) + process.exit() + }) + process.on('message', (message: NuxtParentIPCMessage) => { + if (message.type === 'nuxt:internal:dev:context') { + initialize(message.context) + } + }) + this.send({ type: 'nuxt:internal:dev:fork-ready' }) + } + + send(message: T) { + if (this.enabled) { + process.send?.(message) + } + } +} + +const ipc = new IPC() + +export async function initialize(devContext: NuxtDevContext, ctx: InitializeOptions = {}, listenOptions?: Partial) { + const devServerOverrides = resolveDevServerOverrides({ + public: devContext.public, + }) + + const devServerDefaults = resolveDevServerDefaults({ + hostname: devContext.hostname, + https: devContext.proxy?.https, + }, devContext.publicURLs) + + // Init Nuxt dev + const devServer = await createNuxtDevServer({ + cwd: devContext.cwd, + overrides: defu(ctx.data?.overrides, devServerOverrides), + defaults: devServerDefaults, + logLevel: devContext.args.logLevel as 'silent' | 'info' | 'verbose', + clear: !!devContext.args.clear, + dotenv: { cwd: devContext.cwd, fileName: devContext.args.dotenv }, + envName: devContext.args.envName, + port: process.env._PORT ?? undefined, + devContext, + }, listenOptions) + + let port: number + + if (ipc.enabled) { + devServer.on('loading:error', (_error) => { + ipc.send({ + type: 'nuxt:internal:dev:loading:error', + error: { + message: _error.message, + stack: _error.stack, + name: _error.name, + code: _error.code, + }, + }) + }) + devServer.on('loading', (message) => { + ipc.send({ type: 'nuxt:internal:dev:loading', message }) + }) + devServer.on('restart', () => { + ipc.send({ type: 'nuxt:internal:dev:restart' }) + }) + devServer.on('ready', (payload) => { + ipc.send({ type: 'nuxt:internal:dev:ready', port: payload.port }) + }) + } + else { + devServer.on('ready', (payload) => { + port = payload.port + }) + } + + // Init server + await devServer.init() + + if (process.env.DEBUG) { + // eslint-disable-next-line no-console + console.debug(`Dev server (internal) initialized in ${Date.now() - start}ms`) + } + + return { + listener: devServer.listener, + close: () => devServer.close(), + onReady: (callback: (port: number) => void) => { + if (port) { + callback(port) + } + else { + devServer.once('ready', payload => callback(payload.port)) + } + }, + onRestart: (callback: (devServer: NuxtDevServer) => void) => { + devServer.once('restart', () => callback(devServer)) + }, + } +} diff --git a/packages/nuxi/src/utils/dev.ts b/packages/nuxi/src/dev/utils.ts similarity index 79% rename from packages/nuxi/src/utils/dev.ts rename to packages/nuxi/src/dev/utils.ts index d8adf901a..db5bc642a 100644 --- a/packages/nuxi/src/utils/dev.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -1,8 +1,8 @@ import type { Nuxt, NuxtConfig } from '@nuxt/schema' import type { DotenvOptions } from 'c12' import type { FSWatcher } from 'chokidar' -import type { Jiti } from 'jiti' import type { HTTPSOptions, Listener, ListenOptions, ListenURL } from 'listhen' +import type { NitroDevServer } from 'nitropack' import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http' import type { AddressInfo } from 'node:net' @@ -12,7 +12,6 @@ import process from 'node:process' import chokidar from 'chokidar' import defu from 'defu' import { toNodeListener } from 'h3' -import { createJiti } from 'jiti' import { listen } from 'listhen' import { join, relative, resolve } from 'pathe' import { debounce } from 'perfect-debounce' @@ -21,11 +20,15 @@ import { joinURL } from 'ufo' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' -import { logger } from '../utils/logger' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' + import { renderError } from './error' +export type NuxtParentIPCMessage = + | { type: 'nuxt:internal:dev:context', context: NuxtDevContext } + export type NuxtDevIPCMessage = + | { type: 'nuxt:internal:dev:fork-ready' } | { type: 'nuxt:internal:dev:ready', port: number } | { type: 'nuxt:internal:dev:loading', message: string } | { type: 'nuxt:internal:dev:restart' } @@ -33,9 +36,16 @@ export type NuxtDevIPCMessage = | { type: 'nuxt:internal:dev:loading:error', error: Error } export interface NuxtDevContext { + cwd: string public?: boolean hostname?: string publicURLs?: string[] + args: { + clear: boolean + logLevel: string + dotenv: string + envName: string + } proxy?: { url?: string urls?: ListenURL[] @@ -78,10 +88,7 @@ export async function createNuxtDevServer(options: NuxtDevServerOptions, listenO } if (options.devContext.proxy?.urls) { const _getURLs = devServer.listener.getURLs.bind(devServer.listener) - devServer.listener.getURLs = async () => - Array.from( - new Set([...options.devContext.proxy!.urls!, ...(await _getURLs())]), - ) + devServer.listener.getURLs = async () => Array.from(new Set([...options.devContext.proxy?.urls || [], ...(await _getURLs())])) } return devServer @@ -90,13 +97,15 @@ export async function createNuxtDevServer(options: NuxtDevServerOptions, listenO // https://regex101.com/r/7HkR5c/1 const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ -class NuxtDevServer extends EventEmitter { +type NuxtWithServer = Omit & { server?: NitroDevServer } + +export class NuxtDevServer extends EventEmitter { private _handler?: RequestListener private _distWatcher?: FSWatcher - private _currentNuxt?: Nuxt + private _currentNuxt?: NuxtWithServer private _loadingMessage?: string - private _jiti: Jiti private _loadingError?: Error + private cwd: string loadDebounced: (reload?: boolean, reason?: string) => void handler: RequestListener @@ -115,7 +124,7 @@ class NuxtDevServer extends EventEmitter { _initResolve() }) - this._jiti = createJiti(options.cwd) + this.cwd = options.cwd this.handler = async (req, res) => { if (this._loadingError) { @@ -139,14 +148,20 @@ class NuxtDevServer extends EventEmitter { renderError(req, res, this._loadingError) } + async resolveLoadingTemplate() { + const { createJiti } = await import('jiti') + const jiti = createJiti(this.cwd) + const loading = await jiti.import<{ loading: () => string }>('@nuxt/ui-templates').then(r => r.loading).catch(() => {}) + + return loading || ((params: { loading: string }) => `

${params.loading}

`) + } + async _renderLoadingScreen(req: IncomingMessage, res: ServerResponse) { res.statusCode = 503 res.setHeader('Content-Type', 'text/html') - const loadingTemplate - = this.options.loadingTemplate - || this._currentNuxt?.options.devServer.loadingTemplate - || await this._jiti.import<{ loading: () => string }>('@nuxt/ui-templates').then(r => r.loading).catch(() => {}) - || ((params: { loading: string }) => `

${params.loading}

`) + const loadingTemplate = this.options.loadingTemplate + || this._currentNuxt?.options.devServer.loadingTemplate + || await this.resolveLoadingTemplate() res.end( loadingTemplate({ loading: this._loadingMessage || 'Loading...', @@ -165,7 +180,7 @@ class NuxtDevServer extends EventEmitter { this._loadingError = undefined } catch (error) { - logger.error(`Cannot ${reload ? 'restart' : 'start'} nuxt: `, error) + console.error(`Cannot ${reload ? 'restart' : 'start'} nuxt: `, error) this._handler = undefined this._loadingError = error as Error this._loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' @@ -188,14 +203,15 @@ class NuxtDevServer extends EventEmitter { this._handler = undefined this.emit('loading', this._loadingMessage) if (reload) { - logger.info(this._loadingMessage) + // eslint-disable-next-line no-console + console.info(this._loadingMessage) } await this.close() const kit = await loadKit(this.options.cwd) - const devServerDefaults = _getDevServerDefaults({}, await this.listener.getURLs().then(r => r.map(r => r.url))) + const devServerDefaults = resolveDevServerDefaults({}, await this.listener.getURLs().then(r => r.map(r => r.url))) this._currentNuxt = await kit.loadNuxt({ cwd: this.options.cwd, @@ -269,22 +285,19 @@ class NuxtDevServer extends EventEmitter { }) if (this._currentNuxt.server && 'upgrade' in this._currentNuxt.server) { - this.listener.server.on( - 'upgrade', - async (req: any, socket: any, head: any) => { - const nuxt = this._currentNuxt - if (!nuxt) - return - const viteHmrPath = joinURL( - nuxt.options.app.baseURL.startsWith('./') ? nuxt.options.app.baseURL.slice(1) : nuxt.options.app.baseURL, - nuxt.options.app.buildAssetsDir, - ) - if (req.url.startsWith(viteHmrPath)) { - return // Skip for Vite HMR - } - await nuxt.server.upgrade(req, socket, head) - }, - ) + this.listener.server.on('upgrade', (req, socket, head) => { + const nuxt = this._currentNuxt + if (!nuxt || !nuxt.server) + return + const viteHmrPath = joinURL( + nuxt.options.app.baseURL.startsWith('./') ? nuxt.options.app.baseURL.slice(1) : nuxt.options.app.baseURL, + nuxt.options.app.buildAssetsDir, + ) + if (req.url?.startsWith(viteHmrPath)) { + return // Skip for Vite HMR + } + nuxt.server.upgrade(req, socket, head) + }) } await this._currentNuxt.hooks.callHook('listen', this.listener.server, this.listener) @@ -294,12 +307,12 @@ class NuxtDevServer extends EventEmitter { const addr = this.listener.address this._currentNuxt.options.devServer.host = addr.address this._currentNuxt.options.devServer.port = addr.port - this._currentNuxt.options.devServer.url = _getAddressURL(addr, !!this.listener.https) + this._currentNuxt.options.devServer.url = getAddressURL(addr, !!this.listener.https) this._currentNuxt.options.devServer.https = this.options.devContext.proxy ?.https as boolean | { key: string, cert: string } if (this.listener.https && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { - logger.warn('You might need `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable to make https work.') + console.warn('You might need `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable to make https work.') } await Promise.all([ @@ -307,6 +320,10 @@ class NuxtDevServer extends EventEmitter { kit.buildNuxt(this._currentNuxt), ]) + if (!this._currentNuxt.server) { + throw new Error('Nitro server has not been initialized.') + } + // Watch dist directory this._distWatcher = chokidar.watch(resolve(this._currentNuxt.options.buildDir, 'dist'), { ignoreInitial: true, @@ -340,7 +357,7 @@ class NuxtDevServer extends EventEmitter { } } -function _getAddressURL(addr: AddressInfo, https: boolean) { +function getAddressURL(addr: AddressInfo, https: boolean) { const proto = https ? 'https' : 'http' let host = addr.address.includes(':') ? `[${addr.address}]` : addr.address if (host === '[::]') { @@ -350,18 +367,18 @@ function _getAddressURL(addr: AddressInfo, https: boolean) { return `${proto}://${host}:${port}/` } -export function _getDevServerOverrides(listenOptions: Partial>) { +export function resolveDevServerOverrides(listenOptions: Partial>) { if (listenOptions.public || provider === 'codesandbox') { return { devServer: { cors: { origin: '*' } }, vite: { server: { allowedHosts: true } }, - } + } as const } return {} } -export function _getDevServerDefaults(listenOptions: Partial>, urls: string[] = []) { +export function resolveDevServerDefaults(listenOptions: Partial>, urls: string[] = []) { const defaultConfig: Partial = {} if (urls) { diff --git a/packages/nuxi/src/run.ts b/packages/nuxi/src/run.ts index 16536689c..6d3d2cc63 100644 --- a/packages/nuxi/src/run.ts +++ b/packages/nuxi/src/run.ts @@ -10,12 +10,10 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback startTime: Date.now(), entry: fileURLToPath( - new URL( - import.meta.url.endsWith('.ts') - ? '../bin/nuxi.mjs' - : '../../bin/nuxi.mjs', - import.meta.url, - ), + new URL('../../bin/nuxi.mjs', import.meta.url), + ), + devEntry: fileURLToPath( + new URL('../dev/index.mjs', import.meta.url), ), } diff --git a/packages/nuxi/src/utils/banner.ts b/packages/nuxi/src/utils/banner.ts index 635800827..693a68303 100644 --- a/packages/nuxi/src/utils/banner.ts +++ b/packages/nuxi/src/utils/banner.ts @@ -1,26 +1,28 @@ +import { readFileSync } from 'node:fs' + import { colors } from 'consola/utils' -import { readPackageJSON } from 'pkg-types' +import { resolveModulePath } from 'exsolve' import { tryResolveNuxt } from './kit' import { logger } from './logger' -export async function showVersions(cwd: string) { +export function showVersions(cwd: string) { const { bold, gray, green } = colors - const nuxtDir = await tryResolveNuxt(cwd) - async function getPkgVersion(pkg: string) { + const nuxtDir = tryResolveNuxt(cwd) + function getPkgVersion(pkg: string) { for (const url of [cwd, nuxtDir]) { if (!url) { continue } - const p = await readPackageJSON(pkg, { url }).catch(() => null) + const p = resolveModulePath(`${pkg}/package.json`, { from: url, try: true }) if (p) { - return p.version! + return JSON.parse(readFileSync(p, 'utf-8')).version as string } } return '' } - const nuxtVersion = await getPkgVersion('nuxt') || await getPkgVersion('nuxt-nightly') || await getPkgVersion('nuxt3') || await getPkgVersion('nuxt-edge') - const nitroVersion = await getPkgVersion('nitropack') || await getPkgVersion('nitropack-nightly') || await getPkgVersion('nitropack-edge') + const nuxtVersion = getPkgVersion('nuxt') || getPkgVersion('nuxt-nightly') || getPkgVersion('nuxt3') || getPkgVersion('nuxt-edge') + const nitroVersion = getPkgVersion('nitropack') || getPkgVersion('nitropack-nightly') || getPkgVersion('nitropack-edge') logger.log(gray(green(`Nuxt ${bold(nuxtVersion)}`) + (nitroVersion ? ` with Nitro ${bold(nitroVersion)}` : ''))) } diff --git a/packages/nuxi/src/utils/kit.ts b/packages/nuxi/src/utils/kit.ts index 90ce29078..dcb51fc8c 100644 --- a/packages/nuxi/src/utils/kit.ts +++ b/packages/nuxi/src/utils/kit.ts @@ -1,13 +1,17 @@ -import { createJiti } from 'jiti' +import { pathToFileURL } from 'node:url' +import { resolveModulePath } from 'exsolve' export async function loadKit(rootDir: string): Promise { - const jiti = createJiti(rootDir) try { // Without PNP (or if users have a local install of kit, we bypass resolving from Nuxt) - const localKit = jiti.esmResolve('@nuxt/kit', { try: true }) - // Otherwise, we resolve Nuxt _first_ as it is Nuxt's kit dependency that will be used - const rootURL = localKit ? rootDir : (await tryResolveNuxt(rootDir)) || rootDir - let kit: typeof import('@nuxt/kit') = await jiti.import('@nuxt/kit', { parentURL: rootURL }) + let kitPath = resolveModulePath('@nuxt/kit', { try: true, from: rootDir }) + if (!kitPath) { + // Otherwise, we resolve Nuxt _first_ as it is Nuxt's kit dependency that will be used + const nuxtPath = tryResolveNuxt(rootDir) + kitPath = resolveModulePath('@nuxt/kit', { from: nuxtPath || rootDir }) + } + + let kit: typeof import('@nuxt/kit') = await import(pathToFileURL(kitPath).href) if (!kit.writeTypes) { kit = { ...kit, @@ -21,17 +25,16 @@ export async function loadKit(rootDir: string): Promise { + it.skip('should start and return HTML', async () => { + const html = await $fetch('/') + + expect(html).toContain('Welcome to the Nuxt CLI playground') + }) +}) diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts new file mode 100644 index 000000000..3b11bf3c4 --- /dev/null +++ b/packages/nuxt-cli/src/dev/index.ts @@ -0,0 +1 @@ +export { initialize } from '../../../nuxi/src/dev' diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index 978f9eb5c..5610b6f56 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -10,12 +10,10 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback startTime: Date.now(), entry: fileURLToPath( - new URL( - import.meta.url.endsWith('.ts') - ? '../bin/nuxi.mjs' - : '../../bin/nuxi.mjs', - import.meta.url, - ), + new URL('../../bin/nuxi.mjs', import.meta.url), + ), + devEntry: fileURLToPath( + new URL('../dev/index.mjs', import.meta.url), ), } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d619533..61a2e0496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: defu: specifier: ^6.1.4 version: 6.1.4 + exsolve: + specifier: ^1.0.5 + version: 1.0.5 fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -232,6 +235,9 @@ importers: defu: specifier: ^6.1.4 version: 6.1.4 + exsolve: + specifier: ^1.0.5 + version: 1.0.5 fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -317,6 +323,10 @@ importers: nuxt: specifier: ^3.16.1 version: 3.17.4(@parcel/watcher@2.5.1)(@types/node@22.15.29)(db0@0.3.2)(eslint@9.28.0(jiti@2.4.2))(ioredis@5.6.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.41.1)(terser@5.40.0)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(terser@5.40.0)(yaml@2.8.0))(yaml@2.8.0) + devDependencies: + '@nuxt/test-utils': + specifier: ^3.19.1 + version: 3.19.1(@types/node@22.15.29)(jiti@2.4.2)(magicast@0.3.5)(terser@5.40.0)(typescript@5.8.3)(vitest@3.2.0(@types/debug@4.1.12)(@types/node@22.15.29)(jiti@2.4.2)(terser@5.40.0)(yaml@2.8.0))(yaml@2.8.0) packages: diff --git a/types.d.ts b/types.d.ts index 4c154ee0e..0573975bf 100644 --- a/types.d.ts +++ b/types.d.ts @@ -6,6 +6,7 @@ declare global { | undefined | { entry: string + devEntry: string startTime: number } }