- Nitro middleware resolves page requests once and hands the answer to the render (event.context.seo); the route middleware reuses it - useCmsSeo: html lang, hreflang links, og:locale and exactly one JSON-LD script with one @graph (CMS nodes plus useSchemaNode nodes, merged by @id) - local module (configKey seo): cms (alias of public.seo.enabled), breadcrumbs, links.newTabHint, rootFiles, resolveFiles - GET /api/__seo__/resolve for client navigation through the Nuxt server - smoke harness: count=, exact header=, noheader=, reqheader=, repeated options, resolves/apicalls/noreport lines, --no-site; v0.1.1 suite runs unchanged against a backend without /site
78 lines
2.8 KiB
JavaScript
78 lines
2.8 KiB
JavaScript
// Boots a built Nuxt server (`<outputDir>/server/index.mjs`) on 127.0.0.1
|
|
// against a given SEO API base and waits until it answers HTTP. The same
|
|
// helper as vue-journal-layer's testing/nuxt-server.mjs, with the SEO base.
|
|
// Plain .mjs with JSDoc types so it runs from node_modules unchanged.
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { createServer } from 'node:net'
|
|
import { existsSync } from 'node:fs'
|
|
import { join, resolve } from 'node:path'
|
|
|
|
/** @returns {Promise<number>} */
|
|
function freePort() {
|
|
return new Promise((done, fail) => {
|
|
const srv = createServer()
|
|
srv.once('error', fail)
|
|
srv.listen(0, '127.0.0.1', () => {
|
|
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
|
|
srv.close(() => done(port))
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The returned `output()` gives the server's captured stdout and stderr so
|
|
* far, for checks on what the server logged.
|
|
*
|
|
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
|
|
* @returns {Promise<{ url: string, close: () => Promise<void>, output: () => string }>}
|
|
*/
|
|
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
|
|
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
|
if (!existsSync(entry)) throw new Error(`nuxt-server: ${entry} does not exist; run pnpm build:playground first`)
|
|
const listenPort = port || (await freePort())
|
|
const url = `http://127.0.0.1:${listenPort}`
|
|
|
|
const child = spawn(process.execPath, [entry], {
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: 'production',
|
|
NITRO_HOST: '127.0.0.1',
|
|
NITRO_PORT: String(listenPort),
|
|
NUXT_SEO_API_BASE: apiBase,
|
|
...env,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
let log = ''
|
|
child.stdout.on('data', (b) => (log += b))
|
|
child.stderr.on('data', (b) => (log += b))
|
|
let exited = false
|
|
child.once('exit', () => (exited = true))
|
|
|
|
const close = () =>
|
|
new Promise((/** @type {(v?: void) => void} */ done) => {
|
|
if (exited) return done()
|
|
child.once('exit', () => done())
|
|
child.kill('SIGTERM')
|
|
setTimeout(() => {
|
|
if (!exited) child.kill('SIGKILL')
|
|
}, 5000).unref()
|
|
})
|
|
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
|
|
try {
|
|
// A Nuxt-internal path: it touches no page, so the boot probe never
|
|
// resolves through the SEO API or fills a route cache.
|
|
await fetch(url + '/_nuxt/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
|
return { url, close, output: () => log }
|
|
} catch {
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
}
|
|
}
|
|
await close()
|
|
throw new Error(`nuxt-server: no answer on ${url} within ${timeoutMs} ms\n${log}`)
|
|
}
|