From 96b8d40f34ee1dd5f2d6accc2c00ed9534b2b967 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 23 Feb 2024 17:35:19 +0000 Subject: [PATCH] feat(nuxt): pass + stream server logs to client --- docs/3.api/6.advanced/1.hooks.md | 1 + .../src/app/plugins/dev-server-logs.client.ts | 80 ++++++++++++++ packages/nuxt/src/app/types/augments.d.ts | 2 + packages/nuxt/src/core/nuxt.ts | 15 ++- .../src/core/runtime/nitro/dev-server-logs.ts | 101 ++++++++++++++++++ packages/nuxt/src/core/templates.ts | 1 + packages/schema/src/config/experimental.ts | 11 ++ 7 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 packages/nuxt/src/app/plugins/dev-server-logs.client.ts create mode 100644 packages/nuxt/src/core/runtime/nitro/dev-server-logs.ts diff --git a/docs/3.api/6.advanced/1.hooks.md b/docs/3.api/6.advanced/1.hooks.md index 35520e27c314..38288f728a9b 100644 --- a/docs/3.api/6.advanced/1.hooks.md +++ b/docs/3.api/6.advanced/1.hooks.md @@ -28,6 +28,7 @@ Hook | Arguments | Environment | Description `page:loading:start` | - | Client | Called when the `setup()` of the new page is running. `page:loading:end` | - | Client | Called after `page:finish` `page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event. +`dev:ssr-logs` | `logs` | Client | Called with an array of server-side logs that have been passed to the client (if `features.devLogs` is enabled). ## Nuxt Hooks (build time) diff --git a/packages/nuxt/src/app/plugins/dev-server-logs.client.ts b/packages/nuxt/src/app/plugins/dev-server-logs.client.ts new file mode 100644 index 000000000000..50f18ef8a1f5 --- /dev/null +++ b/packages/nuxt/src/app/plugins/dev-server-logs.client.ts @@ -0,0 +1,80 @@ +import { consola, createConsola } from 'consola' +import type { LogObject } from 'consola' +import { isAbsolute } from 'pathe' + +import { defineNuxtPlugin } from '../nuxt' + +// @ts-expect-error virtual file +import { devLogs, devRootDir } from '#build/nuxt.config.mjs' + +declare module '#app' { + interface RuntimeNuxtHooks { + 'dev:ssr-logs': (logs: LogObject[]) => void | Promise + } +} + +export default defineNuxtPlugin(nuxtApp => { + // Show things in console + if (devLogs !== 'silent') { + const logger = createConsola({ + formatOptions: { + colors: true, + date: true, + } + }) + const hydrationLogs = new Set() + consola.wrapAll() + consola.addReporter({ + log (logObj) { + try { + hydrationLogs.add(JSON.stringify(logObj.args)) + } catch { + // silently ignore - the worst case is a user gets log twice + } + } + }) + nuxtApp.hook('dev:ssr-logs', logs => { + for (const log of logs) { + // deduplicate so we don't print out things that are logged on client + if (!hydrationLogs.size || !hydrationLogs.has(JSON.stringify(log.args))) { + logger.log(normalizeServerLog({ ...log })) + } + } + }) + + nuxtApp.hooks.hook('app:suspense:resolve', () => logger.restoreAll()) + nuxtApp.hooks.hookOnce('dev:ssr-logs', () => hydrationLogs.clear()) + } + + // pass SSR logs after hydration + nuxtApp.hooks.hook('app:suspense:resolve', async () => { + if (window && window.__NUXT_LOGS__) { + await nuxtApp.hooks.callHook('dev:ssr-logs', window.__NUXT_LOGS__) + } + }) + + // initialise long-running SSE connection + const source = new EventSource('/_nuxt_logs') + source.onmessage = (event) => { + const log = JSON.parse(event.data) as LogObject + log.date = new Date(log.date) + nuxtApp.hooks.callHook('dev:ssr-logs', [log]) + } +}) + +function normalizeFilenames (stack?: string) { + return stack?.replace(/at.*\(([^)]+)\)/g, (match, filename) => { + if (!isAbsolute(filename)) { return match } + // TODO: normalise file names for clickable links in console + return match.replace(filename, filename.replace(devRootDir, '')) + }) +} + +function normalizeServerLog (log: LogObject) { + if (log.type === 'error' || log.type === 'warn') { + log.additional = normalizeFilenames(log.stack as string) + } + log.tag = `[ssr]${log.filename ? ` ${log.filename}` : ''}${log.tag || ''}` + delete log.stack + return log +} diff --git a/packages/nuxt/src/app/types/augments.d.ts b/packages/nuxt/src/app/types/augments.d.ts index e9ea30b9274c..1f9a89e8bf7b 100644 --- a/packages/nuxt/src/app/types/augments.d.ts +++ b/packages/nuxt/src/app/types/augments.d.ts @@ -1,4 +1,5 @@ import type { UseHeadInput } from '@unhead/vue' +import type { LogObject } from 'consola' import type { NuxtApp, useNuxtApp } from '../nuxt' interface NuxtStaticBuildFlags { @@ -17,6 +18,7 @@ declare global { interface ImportMeta extends NuxtStaticBuildFlags {} interface Window { + __NUXT_LOGS__?: LogObject[] __NUXT__?: Record useNuxtApp?: typeof useNuxtApp } diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index adc2aac1fae9..f5a5dc93681f 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -1,7 +1,7 @@ import { join, normalize, relative, resolve } from 'pathe' import { createDebugger, createHooks } from 'hookable' import type { LoadNuxtOptions } from '@nuxt/kit' -import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' +import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addServerPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema' import escapeRE from 'escape-string-regexp' @@ -158,6 +158,19 @@ async function initNuxt (nuxt: Nuxt) { addPlugin(resolve(nuxt.options.appDir, 'plugins/check-if-layout-used')) } + if (nuxt.options.dev && nuxt.options.features.devLogs) { + addPlugin(resolve(nuxt.options.appDir, 'plugins/dev-server-logs.client')) + addServerPlugin(resolve(distDir, 'core/runtime/nitro/dev-server-logs')) + nuxt.options.nitro = defu(nuxt.options.nitro, { + externals: { + inline: [/#internal\/dev-server-logs-options/] + }, + virtual: { + ['#internal/dev-server-logs-options']: () => `export const rootDir = ${JSON.stringify(nuxt.options.rootDir)};` + } + }) + } + // Transform initial composable call within ``) + logs.length = 0 + }) +} + +const EXCLUDE_TRACE_RE = new RegExp('^.*at.*(\\/node_modules\\/(.*\\/)?(nuxt|consola|@vue)\\/.*|core\\/runtime\\/nitro.*)$\\n?', 'gm') +function getStack () { + // Pass along stack traces if needed (for error and warns) + const stack = new Error() + Error.captureStackTrace(stack) + return stack.stack?.replace(EXCLUDE_TRACE_RE, '').replace(/^Error.*\n/, '') || '' +} + +const FILENAME_RE = /at.*\(([^:)]+)[):]/ +const FILENAME_RE_GLOBAL = /at.*\(([^)]+)\)/g +function extractFilenameFromStack (stacktrace: string) { + return stacktrace.match(FILENAME_RE)?.[1].replace(withTrailingSlash(rootDir), '') +} +function normalizeFilenames (stacktrace: string) { + // remove line numbers and file: protocol - TODO: sourcemap support for line numbers + return stacktrace.replace(FILENAME_RE_GLOBAL, (match, filename) => match.replace(filename, filename.replace('file:///', '/').replace(/:.*$/, ''))) +} + +function onConsoleLog (callback: (log: LogObject) => void) { + const logger = createConsola({ + reporters: [ + { + log (logObj) { + // Don't swallow log messages in console - is there a better way to do this @pi0? + // TODO: display (clickable) filename in server log as well when we use consola for this + (originalConsole[logObj.type as 'log'] || originalConsole.log)(...logObj.args) + + callback(logObj) + }, + } + ] + }) + logger.wrapAll() +} diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index 55ee480e135f..3d6afc1d9a1b 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -384,6 +384,7 @@ export const nuxtConfigTemplate: NuxtTemplate = { `export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.selectiveClient}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`, `export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`, + `export const devLogs = ${JSON.stringify(ctx.nuxt.options.features.devLogs)}`, `export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`, `export const asyncDataDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.useAsyncData)}`, `export const fetchDefaults = ${JSON.stringify(fetchDefaults)}`, diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 5c969b70669f..f25cfa0d8d01 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -52,6 +52,17 @@ export default defineUntypedSchema({ } }, + /** + * Display logs on the client console that are normally only shown in the server console. + * + * This also enables an ongoing stream of server logs in the browser console as you are developing. + * + * If set to `silent`, the logs will be streamed and you can handle them yourself with the `dev:ssr-logs` hook, + * but they will not be shown in the browser console. + * @type {boolean | 'silent'} + */ + devLogs: true, + /** * Turn off rendering of Nuxt scripts and JS resource hints. * You can also disable scripts more granularly within `routeRules`.