Skip to content

Commit 2d077ed

Browse files
committed
feat(analyze,build,preview,dev): support builds without a server runtime
1 parent e39a041 commit 2d077ed

10 files changed

Lines changed: 359 additions & 20 deletions

File tree

docs/preview.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ npx nuxt preview [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>
1616

1717
The `preview` command starts a server to preview your Nuxt application after running the `build` command. `nuxt start` is the same command under another name. When running your application in production refer to the [Deployment section](/docs/getting-started/deployment).
1818

19+
When the configured `server.builder` produces no server at all (a client-only build, for example), there is nothing to run: the static output is served directly instead, with unmatched paths falling back to the client entry so client-side routing works.
20+
1921
Some Nitro presets do not produce a server that can be run locally. For those, the preset's own preview command is run instead, and the command tells you what it is running.
2022

2123
## Arguments

packages/nuxt-cli/src/commands/analyze.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { styleText } from 'node:util'
77
import { note, taskLog } from '@clack/prompts'
88
import { defineCommand } from 'citty'
99
import { defu } from 'defu'
10-
import { join, relative, resolve } from 'pathe'
10+
import { join, relative } from 'pathe'
1111
import { serve } from 'srvx'
1212

1313
import { resolveDotenvFileNames } from '../utils/args'
@@ -18,6 +18,7 @@ import { loadKit } from '../utils/kit'
1818
import { acquireLock, acquireOutputLock, formatLockError } from '../utils/lockfile'
1919
import { intro, logger, outro } from '../utils/logger'
2020
import { relativeToProcess, resolveRootDir } from '../utils/paths'
21+
import { resolveServerBuild } from '../utils/server-build'
2122
import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'
2223

2324
const NON_WORD_RE = /[^\w-]/g
@@ -80,7 +81,8 @@ export default defineCommand({
8081

8182
const startTime = Date.now()
8283

83-
const { loadNuxt, buildNuxt } = await loadKit(cwd)
84+
const kit = await loadKit(cwd)
85+
const { loadNuxt, buildNuxt } = kit
8486

8587
const nuxt = await loadNuxt({
8688
cwd,
@@ -133,7 +135,7 @@ export default defineCommand({
133135

134136
const analyzeDir = nuxt.options.analyzeDir
135137
const buildDir = nuxt.options.buildDir
136-
const outDir = resolve(nuxt.options.rootDir, nuxt.options.nitro.output?.dir || '.output')
138+
const outDir = resolveServerBuild(kit, nuxt).dir
137139

138140
nuxt.options.build.analyze = defu(nuxt.options.build.analyze, {
139141
filename: join(analyzeDir, 'client.html'),

packages/nuxt-cli/src/commands/build.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { intro, logger, outro } from '../utils/logger'
1818
import { resolveRootDir } from '../utils/paths'
1919
import { createPhaseReporter, formatPhaseBreakdown } from '../utils/phase-reporter'
2020
import { startCpuProfile, stopCpuProfile } from '../utils/profile'
21+
import { resolveServerBuild } from '../utils/server-build'
2122
import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, profileArgs, rootDirArgs } from './_shared'
2223

2324
/** How often a phase repeats itself where there is no animated line. */
@@ -122,15 +123,18 @@ export default defineCommand({
122123
}
123124
releaseLocks.push(lock.release)
124125

125-
const nitro = kit.useNitro()
126-
logger.info(`Nitro preset: ${styleText('cyan', nitro.options.preset)}`)
126+
const serverBuild = resolveServerBuild(kit, nuxt)
127+
if (serverBuild.target) {
128+
logger.info(`${serverBuild.name === 'nitro' ? 'Nitro' : serverBuild.name} preset: ${styleText('cyan', serverBuild.target)}`)
129+
}
127130

128-
const outputLock = acquireOutputLock(nuxt.options.rootDir, nitro.options.output.dir, {
131+
const outputDir = serverBuild.dir
132+
const outputLock = acquireOutputLock(nuxt.options.rootDir, outputDir, {
129133
command: 'build',
130134
cwd,
131135
})
132136
if (outputLock.existing) {
133-
throw new ActionableError(formatLockError(outputLock.existing, { outputDir: relative(process.cwd(), nitro.options.output.dir) }))
137+
throw new ActionableError(formatLockError(outputLock.existing, { outputDir: relative(process.cwd(), outputDir) }))
134138
}
135139
releaseLocks.push(outputLock.release)
136140

@@ -153,7 +157,7 @@ export default defineCommand({
153157
logger.warn(`HTML content not prerendered because ${styleText('cyan', 'ssr: false')} was set.`)
154158
logger.info(`You can read more in ${styleText('cyan', 'https://nuxt.com/docs/getting-started/deployment#static-hosting')}.`)
155159
}
156-
const dir = nitro.options.output.publicDir
160+
const dir = serverBuild.publicDir
157161
const publicDir = dir ? relative(process.cwd(), dir) : '.output/public'
158162
outro(`✨ You can now deploy ${styleText('cyan', publicDir)} to any static hosting! ${styleText('gray', `(${formatDuration(Date.now() - start)})`)}`)
159163
}

packages/nuxt-cli/src/commands/preview.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import { loadKit } from '../utils/kit'
1414
import { logger, outro } from '../utils/logger'
1515
import { withPrependedPath } from '../utils/path-env'
1616
import { relativeToProcess, resolveRootDir } from '../utils/paths'
17+
import { getServerBuilderName } from '../utils/server-build'
18+
import { findStaticEntry, previewStaticOutput } from '../utils/static-preview'
1719
import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'
1820

21+
const TRAILING_SLASH_RE = /\/$/
22+
1923
const command = defineCommand({
2024
meta: {
2125
name: 'preview',
@@ -47,6 +51,8 @@ const command = defineCommand({
4751

4852
let envLoaded = false
4953
let resolvedOutputDir: string | undefined
54+
let resolvedPublicDir: string | undefined
55+
let builderName: string | undefined
5056

5157
try {
5258
const { loadNuxt } = await loadKit(cwd)
@@ -70,6 +76,14 @@ const command = defineCommand({
7076
],
7177
},
7278
})
79+
builderName = getServerBuilderName(nuxt)
80+
// `nuxt.serverOutput` is only present in newer Nuxt; the `nitro:init` hook
81+
// above covers older versions, which only ever built with Nitro.
82+
const serverOutput = (nuxt as { serverOutput?: { dir: () => string, publicDir: () => string } }).serverOutput
83+
if (serverOutput) {
84+
resolvedOutputDir = resolve(serverOutput.dir(), 'nitro.json')
85+
resolvedPublicDir = serverOutput.publicDir()
86+
}
7387
await nuxt.close()
7488
}
7589
catch {}
@@ -79,9 +93,35 @@ const command = defineCommand({
7993
resolve(cwd, '.output', 'nitro.json'),
8094
].filter((path): path is string => !!path))]
8195
const nitroJSONPath = nitroJSONPaths.find(p => existsSync(p))
96+
97+
const port = ctx.args.port
98+
|| process.env.NUXT_PORT
99+
|| process.env.NITRO_PORT
100+
|| process.env.PORT
101+
const host = ctx.args.host
102+
|| process.env.NUXT_HOST
103+
|| process.env.NITRO_HOST
104+
|| process.env.HOST
105+
82106
if (!nitroJSONPath) {
107+
// A build with no server runtime leaves only static files, which the CLI
108+
// can serve itself rather than reporting a missing server entry.
109+
const publicDirs = [...new Set([
110+
resolvedPublicDir,
111+
resolve(cwd, '.output', 'public'),
112+
].filter((path): path is string => !!path))]
113+
for (const dir of publicDirs) {
114+
const entry = findStaticEntry(dir)
115+
if (entry) {
116+
logger.info(`This build has no server, so ${styleText('cyan', relativeToProcess(dir))} is being served statically.`)
117+
const server = await previewStaticOutput({ dir, entry, port, hostname: host })
118+
outro(`Previewing ${styleText('cyan', relativeToProcess(dir))} at ${styleText('cyan', server.url?.replace(TRAILING_SLASH_RE, '') || '')}`)
119+
return
120+
}
121+
}
122+
83123
logger.error(
84-
`Cannot find ${styleText('cyan', 'nitro.json')}. Did you run ${styleText('cyan', 'nuxt build')} first? Search path:\n${nitroJSONPaths.join('\n')}`,
124+
`Cannot find a build to preview${builderName ? ` for the ${styleText('cyan', builderName)} server builder` : ''}. Did you run ${styleText('cyan', 'nuxt build')} first? Search path:\n${[...nitroJSONPaths, ...publicDirs].join('\n')}`,
85125
)
86126
process.exit(1)
87127
}
@@ -145,15 +185,6 @@ const command = defineCommand({
145185
logger.error(`Cannot find ${missing.map(fileName => styleText('cyan', fileName)).join(', ')}.`)
146186
}
147187

148-
const port = ctx.args.port
149-
|| process.env.NUXT_PORT
150-
|| process.env.NITRO_PORT
151-
|| process.env.PORT
152-
const host = ctx.args.host
153-
|| process.env.NUXT_HOST
154-
|| process.env.NITRO_HOST
155-
|| process.env.HOST
156-
157188
outro(`Running ${styleText('cyan', previewCommand)} in ${styleText('cyan', relativeToProcess(outputPath))}`)
158189

159190
const [command, ...commandArgs] = tokenizeArgs(previewCommand) as [string, ...string[]]

packages/nuxt-cli/src/dev/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { loadKit } from '../utils/kit'
3434
import { acquireLock, formatLockError, getTakeoverPid, updateLock } from '../utils/lockfile'
3535
import { debug, logger, writeNotice } from '../utils/logger'
3636
import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt'
37+
import { getServerBuilderName } from '../utils/server-build'
3738
import { renderError, renderErrorAnsi } from './error-lazy'
3839
import { isAllowedHost } from './host-check'
3940
import { bindListener, createListener, matchesBoundTarget, openBrowser, resolveOpenURL } from './listen'
@@ -1156,7 +1157,10 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
11561157
await Promise.all([typesPromise, kit.buildNuxt(this.#currentNuxt)])
11571158

11581159
if (!this.#currentNuxt.server) {
1159-
throw new Error('Nitro server has not been initialized.')
1160+
throw new ActionableError(
1161+
`The ${styleText('cyan', getServerBuilderName(this.#currentNuxt))} server builder did not provide a dev server.\n`
1162+
+ ` A ${styleText('cyan', 'server.builder')} must expose a \`handler\`, \`fetch\` or \`app\` on \`nuxt.server\` to be served by ${styleText('cyan', 'nuxt dev')}.`,
1163+
)
11601164
}
11611165

11621166
const distDir = join(this.#currentNuxt.options.buildDir, 'dist')
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { Nuxt } from '@nuxt/schema'
2+
3+
import { resolve } from 'pathe'
4+
5+
/** Default output locations Nuxt uses when no server builder declares its own. */
6+
const DEFAULT_OUTPUT_DIR = '.output'
7+
const DEFAULT_PUBLIC_DIR = '.output/public'
8+
9+
/**
10+
* A server builder as the CLI needs to see it: a name to print, an optional
11+
* deploy target within that builder, whether a server runtime exists, and where
12+
* the build lands.
13+
*
14+
* `dir` and `publicDir` are getters, not values: a builder may still move its
15+
* output after `nuxt.ready()` (Nitro resolves its preset, then `nitro:config`
16+
* and `nitro.updateConfig()` can both change `output.dir`), so a snapshot taken
17+
* before the build can be wrong by the time it is used.
18+
*/
19+
export interface ServerBuild {
20+
/** The configured server builder, e.g. `nitro` or `vite`. */
21+
name: string
22+
/** A deploy target within that builder, e.g. a Nitro preset. */
23+
target: string | undefined
24+
/** Whether this build produced a server runtime at all. */
25+
hasServer: boolean
26+
/** Root of the build output. */
27+
readonly dir: string
28+
/** Static output served from the root of the deployment. */
29+
readonly publicDir: string
30+
}
31+
32+
/**
33+
* Nuxt fields the CLI reads optimistically: they exist in recent Nuxt only, and
34+
* the CLI supports a range of versions.
35+
*/
36+
interface MaybeModernNuxt extends Nuxt {
37+
serverOutput?: { dir: () => string, publicDir: () => string }
38+
options: Nuxt['options'] & { server?: { builder?: unknown } }
39+
}
40+
41+
interface MaybeModernKit {
42+
tryUseNitro?: () => NitroLike | undefined
43+
useNitro: () => NitroLike
44+
}
45+
46+
interface NitroLike {
47+
options: { preset?: string, output?: { dir?: string, publicDir?: string } }
48+
}
49+
50+
/**
51+
* The Nitro instance for this build, or `undefined` when the configured server
52+
* builder did not create one.
53+
*
54+
* `tryUseNitro` is only present in newer `@nuxt/kit`; on older versions
55+
* `useNitro` throwing means the same thing.
56+
*/
57+
export function tryUseNitro(kit: MaybeModernKit): NitroLike | undefined {
58+
if (typeof kit.tryUseNitro === 'function') {
59+
return kit.tryUseNitro()
60+
}
61+
try {
62+
return kit.useNitro()
63+
}
64+
catch {
65+
return undefined
66+
}
67+
}
68+
69+
/**
70+
* The name of the configured server builder, normalised for display: a module
71+
* specifier such as `@nuxt/vite-server` reads as `vite`.
72+
*/
73+
export function getServerBuilderName(nuxt: Nuxt, hasServer?: boolean): string {
74+
const builder = (nuxt as MaybeModernNuxt).options.server?.builder
75+
if (typeof builder === 'string' && builder) {
76+
return builder.replace(/^@nuxt\//, '').replace(/-server$/, '')
77+
}
78+
if (builder) {
79+
return 'custom'
80+
}
81+
// Nuxt versions without `server.builder` only ever built with Nitro.
82+
return hasServer === false ? 'unknown' : 'nitro'
83+
}
84+
85+
/**
86+
* Describe a loaded Nuxt instance's build in builder-agnostic terms.
87+
*
88+
* Output paths come from `nuxt.serverOutput` where available, falling back to
89+
* the Nitro instance and then to Nuxt's defaults, so this works against Nuxt
90+
* versions that predate either.
91+
*/
92+
export function resolveServerBuild(kit: MaybeModernKit, nuxt: Nuxt): ServerBuild {
93+
const nitro = tryUseNitro(kit)
94+
const serverOutput = (nuxt as MaybeModernNuxt).serverOutput
95+
96+
return {
97+
name: getServerBuilderName(nuxt, !!nitro),
98+
target: nitro?.options.preset,
99+
hasServer: !!nitro,
100+
get dir() {
101+
return serverOutput?.dir()
102+
?? nitro?.options.output?.dir
103+
?? resolve(nuxt.options.rootDir, nuxt.options.nitro?.output?.dir || DEFAULT_OUTPUT_DIR)
104+
},
105+
get publicDir() {
106+
return serverOutput?.publicDir()
107+
?? nitro?.options.output?.publicDir
108+
?? resolve(nuxt.options.rootDir, nuxt.options.nitro?.output?.publicDir || DEFAULT_PUBLIC_DIR)
109+
},
110+
}
111+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Server } from 'srvx'
2+
import { existsSync } from 'node:fs'
3+
4+
import { readFile } from 'node:fs/promises'
5+
6+
import { join } from 'pathe'
7+
import { serve } from 'srvx'
8+
import { staticMiddleware } from 'srvx/static'
9+
10+
/** Files a client-only build uses as its entry, most specific first. */
11+
const SPA_FALLBACKS = ['200.html', 'index.html']
12+
13+
/** Whether a directory looks like the static output of a client-only build. */
14+
export function findStaticEntry(dir: string): string | undefined {
15+
return SPA_FALLBACKS.map(name => join(dir, name)).find(path => existsSync(path))
16+
}
17+
18+
export interface StaticPreviewOptions {
19+
dir: string
20+
entry: string
21+
port?: string
22+
hostname?: string
23+
}
24+
25+
/**
26+
* Serve a build that has no server runtime: static files from disk, with every
27+
* unmatched path answered by the client entry so client-side routing works.
28+
*/
29+
export async function previewStaticOutput(options: StaticPreviewOptions): Promise<Server> {
30+
const server = serve({
31+
port: options.port,
32+
hostname: options.hostname,
33+
middleware: [staticMiddleware({ dir: options.dir })],
34+
async fetch() {
35+
return new Response(await readFile(options.entry), {
36+
headers: { 'content-type': 'text/html;charset=utf-8' },
37+
})
38+
},
39+
})
40+
return await server.ready()
41+
}

packages/nuxt-cli/test/unit/commands/build.spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { runCommand } from 'citty'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33

44
import build from '../../../src/commands/build'
5+
import { logger } from '../../../src/utils/logger'
56

67
const mocks = vi.hoisted(() => ({
78
acquireLock: vi.fn(),
@@ -36,7 +37,7 @@ vi.mock('../../../src/utils/lockfile', () => ({
3637
formatLockError: mocks.formatLockError,
3738
}))
3839
vi.mock('../../../src/utils/logger', () => ({
39-
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
40+
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), message: vi.fn() },
4041
intro: vi.fn(),
4142
outro: vi.fn(),
4243
}))
@@ -145,4 +146,22 @@ describe('build', () => {
145146
expect(mocks.buildNuxt).not.toHaveBeenCalled()
146147
expect(mocks.releaseBuildDir).toHaveBeenCalledOnce()
147148
})
149+
150+
it('builds without a server, locking the default output directory', async () => {
151+
mocks.buildNuxt.mockResolvedValue(undefined)
152+
mocks.useNitro.mockImplementation(() => {
153+
throw new Error('Nitro is not initialized!')
154+
})
155+
mocks.loadNuxt.mockResolvedValue({
156+
hook: vi.fn(),
157+
ready: vi.fn(),
158+
options: { buildDir, rootDir: cwd, ssr: false, server: { builder: 'vite' } },
159+
})
160+
161+
await run()
162+
163+
expect(mocks.acquireOutputLock).toHaveBeenCalledWith(cwd, outputDir, { command: 'build', cwd })
164+
expect(mocks.buildNuxt).toHaveBeenCalled()
165+
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('preset'))
166+
})
148167
})

0 commit comments

Comments
 (0)