diff --git a/client/app/app.vue b/client/app/app.vue
index f60578f..16adbd8 100644
--- a/client/app/app.vue
+++ b/client/app/app.vue
@@ -1,7 +1,155 @@
diff --git a/client/app/composables/features.ts b/client/app/composables/features.ts
index b4f4cd2..2ceb29f 100644
--- a/client/app/composables/features.ts
+++ b/client/app/composables/features.ts
@@ -1,4 +1,5 @@
import type { FeaturesName } from '../../../src/runtime/core/types'
+import { useDevtoolsClient } from '@nuxt/devtools-kit/iframe-client'
export function useHintsConfig() {
const hostNuxt = useHostNuxt()
@@ -7,6 +8,10 @@ export function useHintsConfig() {
}
export function useEnabledHintsFeatures(): Record {
+ const client = useDevtoolsClient().value
+ if (!client?.host?.nuxt) {
+ return { hydration: true, lazyLoad: true, webVitals: false, thirdPartyScripts: false, htmlValidate: true }
+ }
const config = useHintsConfig()
return Object.fromEntries(
Object.entries(config.features).map(([feature, flags]) => [
@@ -17,6 +22,8 @@ export function useEnabledHintsFeatures(): Record {
}
export function useHintsFeature(feature: FeaturesName): boolean {
+ const client = useDevtoolsClient().value
+ if (!client?.host?.nuxt) return true
const config = useHintsConfig()
return typeof config.features[feature] === 'object' ? config.features[feature].devtools !== false : Boolean(config.features[feature])
}
diff --git a/client/app/composables/host.ts b/client/app/composables/host.ts
index 7e05066..9412098 100644
--- a/client/app/composables/host.ts
+++ b/client/app/composables/host.ts
@@ -53,8 +53,8 @@ export function useHostHydration() {
export function useHostNuxt() {
const client = useDevtoolsClient().value
- if (!client) {
- throw new Error('`useHostNuxt` must be used when the devtools client is connected')
+ if (!client?.host?.nuxt) {
+ throw new Error('`useHostNuxt` must be used when the devtools client is connected to a host app')
}
return client.host.nuxt
diff --git a/client/app/plugins/0.sse.ts b/client/app/plugins/0.sse.ts
deleted file mode 100644
index d996efb..0000000
--- a/client/app/plugins/0.sse.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { HINTS_SSE_ROUTE } from '../utils/routes'
-
-export default defineNuxtPlugin(() => {
- if (import.meta.test) return
- const eventSource = useEventSource(HINTS_SSE_ROUTE, undefined, {
- autoReconnect: {
- retries: 5,
- onFailed() {
- console.error(new Error('[@nuxt/hints] Failed to connect to hints SSE after 5 attempts.'))
- },
- },
- })
-
- return {
- provide: {
- sse: eventSource,
- },
- }
-})
diff --git a/client/app/plugins/html-validate.ts b/client/app/plugins/html-validate.ts
deleted file mode 100644
index 2506aad..0000000
--- a/client/app/plugins/html-validate.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { HtmlValidateReport } from '../../../src/runtime/html-validate/types'
-import { defineNuxtPlugin } from '#imports'
-import { HTML_VALIDATE_ROUTE } from '../utils/routes'
-
-export default defineNuxtPlugin(() => {
- if (import.meta.test || !useHintsFeature('htmlValidate')) return
- const nuxtApp = useNuxtApp()
-
- const { data: htmlValidateReports } = useLazyFetch(new URL(HTML_VALIDATE_ROUTE, window.location.origin).href, {
- default: () => [],
- deep: true,
- })
-
- function htmlValidateReportHandler(event: MessageEvent) {
- try {
- const payload: HtmlValidateReport = JSON.parse(event.data)
- if (!htmlValidateReports.value.some(existing => existing.id === payload.id)) {
- htmlValidateReports.value = [...htmlValidateReports.value, payload]
- }
- }
- catch {
- console.warn('[hints] Ignoring malformed hints:html-validate:report event', event.data)
- }
- }
-
- function htmlValidateDeletedHandler(event: MessageEvent) {
- try {
- const deletedId = JSON.parse(event.data)
- htmlValidateReports.value = htmlValidateReports.value.filter(report => report.id !== deletedId)
- }
- catch {
- console.warn('[hints] Ignoring malformed hints:html-validate:deleted event', event.data)
- }
- }
-
- useEventListener(nuxtApp.$sse.eventSource, 'hints:html-validate:report', htmlValidateReportHandler)
- useEventListener(nuxtApp.$sse.eventSource, 'hints:html-validate:deleted', htmlValidateDeletedHandler)
-
- return {
- provide: {
- htmlValidateReports,
- },
- }
-})
diff --git a/client/app/plugins/hydration.ts b/client/app/plugins/hydration.ts
deleted file mode 100644
index af0e0f0..0000000
--- a/client/app/plugins/hydration.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { HydrationMismatchPayload, HydrationMismatchResponse, LocalHydrationMismatch } from '../../../src/runtime/hydration/types'
-import { defineNuxtPlugin, useHostNuxt, ref } from '#imports'
-import { HYDRATION_ROUTE } from '../utils/routes'
-
-export default defineNuxtPlugin(() => {
- if (import.meta.test || !useHintsFeature('hydration')) return
- const host = useHostNuxt()
- const nuxtApp = useNuxtApp()
- const hydrationMismatches = ref<(HydrationMismatchPayload | LocalHydrationMismatch)[]>([])
-
- hydrationMismatches.value = [...host.payload.__hints.hydration]
-
- $fetch(new URL(HYDRATION_ROUTE, window.location.origin).href).then((data: { mismatches: HydrationMismatchPayload[] }) => {
- hydrationMismatches.value = [...hydrationMismatches.value, ...data.mismatches.filter(m => !hydrationMismatches.value.some(existing => existing.id === m.id))]
- })
-
- const hydrationMismatchHandler = (event: MessageEvent) => {
- const mismatch: HydrationMismatchPayload = JSON.parse(event.data)
- if (!hydrationMismatches.value.some(existing => existing.id === mismatch.id)) {
- hydrationMismatches.value.push(mismatch)
- }
- }
-
- const hydrationClearedHandler = (event: MessageEvent) => {
- const clearedIds: string[] = JSON.parse(event.data)
- hydrationMismatches.value = hydrationMismatches.value.filter(m => !clearedIds.includes(m.id))
- }
-
- watch(nuxtApp.$sse.eventSource, (newEventSource, oldEventSource) => {
- if (newEventSource) {
- newEventSource.addEventListener('hints:hydration:mismatch', hydrationMismatchHandler)
- newEventSource.addEventListener('hints:hydration:cleared', hydrationClearedHandler)
- }
- if (oldEventSource) {
- oldEventSource.removeEventListener('hints:hydration:mismatch', hydrationMismatchHandler)
- oldEventSource.removeEventListener('hints:hydration:cleared', hydrationClearedHandler)
- }
- }, {
- immediate: true,
- })
-
- return {
- provide: {
- hydrationMismatches,
- },
- }
-})
diff --git a/client/app/plugins/lazy-load.ts b/client/app/plugins/lazy-load.ts
deleted file mode 100644
index cc459d1..0000000
--- a/client/app/plugins/lazy-load.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { ComponentLazyLoadData } from '../../../src/runtime/lazy-load/schema'
-import { parse } from 'valibot'
-import { defineNuxtPlugin } from '#imports'
-import { ComponentLazyLoadDataSchema } from '../../../src/runtime/lazy-load/schema'
-import { LAZY_LOAD_ROUTE } from '../utils/routes'
-
-export default defineNuxtPlugin(() => {
- if (import.meta.test || !useHintsFeature('lazyLoad')) return
- const nuxtApp = useNuxtApp()
-
- const { data: lazyLoadHints } = useLazyFetch(new URL(LAZY_LOAD_ROUTE, window.location.origin).href, {
- default: () => [],
- deep: true,
- })
-
- const lazyLoadReportHandler = (event: MessageEvent) => {
- try {
- const payload = parse(ComponentLazyLoadDataSchema, JSON.parse(event.data))
- if (!lazyLoadHints.value.some(existing => existing.id === payload.id)) {
- lazyLoadHints.value.push(payload)
- }
- }
- catch {
- console.warn('[hints] Ignoring malformed hints:lazy-load:report event', event.data)
- return
- }
- }
-
- const lazyLoadClearedHandler = (event: MessageEvent) => {
- try {
- const clearedId = JSON.parse(event.data)
- lazyLoadHints.value = lazyLoadHints.value.filter(entry => entry.id !== clearedId)
- }
- catch {
- return
- }
- }
-
- watch(nuxtApp.$sse.eventSource, (newEventSource, oldEventSource) => {
- if (newEventSource) {
- newEventSource.addEventListener('hints:lazy-load:report', lazyLoadReportHandler)
- newEventSource.addEventListener('hints:lazy-load:cleared', lazyLoadClearedHandler)
- }
- if (oldEventSource) {
- oldEventSource.removeEventListener('hints:lazy-load:report', lazyLoadReportHandler)
- oldEventSource.removeEventListener('hints:lazy-load:cleared', lazyLoadClearedHandler)
- }
- }, {
- immediate: true,
- })
-
- return {
- provide: {
- lazyLoadHints,
- },
- }
-})
diff --git a/client/app/utils/routes.ts b/client/app/utils/routes.ts
index f05e287..bb5c345 100644
--- a/client/app/utils/routes.ts
+++ b/client/app/utils/routes.ts
@@ -5,7 +5,6 @@
*/
export const HINTS_ROUTE = '/__nuxt_hints'
-export const HINTS_SSE_ROUTE = '/__nuxt_hints/sse'
export const HYDRATION_ROUTE = `${HINTS_ROUTE}/hydration`
export const LAZY_LOAD_ROUTE = `${HINTS_ROUTE}/lazy-load`
export const HTML_VALIDATE_ROUTE = `${HINTS_ROUTE}/html-validate`
diff --git a/src/devtools-handlers.ts b/src/devtools-handlers.ts
new file mode 100644
index 0000000..27b9665
--- /dev/null
+++ b/src/devtools-handlers.ts
@@ -0,0 +1,49 @@
+import { createRouter } from 'h3'
+import {
+ getHydrationMismatches,
+ clearHydrationMismatches,
+ getHandler as hydrationGet,
+ postHandler as hydrationPost,
+ deleteHandler as hydrationDelete,
+} from './runtime/hydration/handlers'
+import {
+ getLazyLoadHints,
+ clearLazyLoadHint,
+ getHandler as lazyLoadGet,
+ postHandler as lazyLoadPost,
+ deleteHandler as lazyLoadDelete,
+} from './runtime/lazy-load/handlers'
+import {
+ getHtmlValidateReports,
+ clearHtmlValidateReport,
+ getHandler as htmlValidateGet,
+ postHandler as htmlValidatePost,
+ deleteHandler as htmlValidateDelete,
+} from './runtime/html-validate/api-handlers'
+
+export {
+ getHydrationMismatches,
+ clearHydrationMismatches,
+ getLazyLoadHints,
+ clearLazyLoadHint,
+ getHtmlValidateReports,
+ clearHtmlValidateReport,
+}
+
+export function createHintsRouter() {
+ const router = createRouter()
+
+ router.get('/hydration', hydrationGet)
+ router.post('/hydration', hydrationPost)
+ router.delete('/hydration', hydrationDelete)
+
+ router.get('/lazy-load', lazyLoadGet)
+ router.post('/lazy-load', lazyLoadPost)
+ router.delete('/lazy-load/:id', lazyLoadDelete)
+
+ router.get('/html-validate', htmlValidateGet)
+ router.post('/html-validate', htmlValidatePost)
+ router.delete('/html-validate/:id', htmlValidateDelete)
+
+ return router
+}
diff --git a/src/devtools.ts b/src/devtools.ts
index ecb44da..1eb0638 100644
--- a/src/devtools.ts
+++ b/src/devtools.ts
@@ -1,8 +1,19 @@
-import { addCustomTab } from '@nuxt/devtools-kit'
+import { addCustomTab, extendServerRpc, onDevToolsInitialized } from '@nuxt/devtools-kit'
import { existsSync } from 'node:fs'
import type { Nuxt } from '@nuxt/schema'
import { addDevServerHandler, type Resolver } from '@nuxt/kit'
import { proxyRequest, eventHandler } from 'h3'
+import type { HintsClientFunctions, HintsServerFunctions } from './runtime/core/rpc-types'
+import { RPC_NAMESPACE } from './runtime/core/rpc-types'
+import {
+ createHintsRouter,
+ getHydrationMismatches,
+ clearHydrationMismatches,
+ getLazyLoadHints,
+ clearLazyLoadHint,
+ getHtmlValidateReports,
+ clearHtmlValidateReport,
+} from './devtools-handlers'
const DEVTOOLS_UI_ROUTE = '/__nuxt-hints'
const DEVTOOLS_UI_LOCAL_PORT = 3300
@@ -41,4 +52,21 @@ export function setupDevToolsUI(nuxt: Nuxt, resolver: Resolver) {
src: DEVTOOLS_UI_ROUTE,
},
}, nuxt)
+
+ addDevServerHandler({
+ route: '/__nuxt_hints',
+ handler: createHintsRouter().handler,
+ })
+
+ onDevToolsInitialized(() => {
+ const rpc = extendServerRpc(RPC_NAMESPACE, {
+ getHydrationMismatches,
+ clearHydrationMismatches,
+ getLazyLoadHints,
+ clearLazyLoadHint,
+ getHtmlValidateReports,
+ clearHtmlValidateReport,
+ })
+ globalThis.__nuxtHintsRpcBroadcast = rpc.broadcast
+ }, nuxt)
}
diff --git a/src/module.ts b/src/module.ts
index caf94c7..691b3c4 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -1,5 +1,4 @@
-import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addServerHandler, addTemplate } from '@nuxt/kit'
-import { HINTS_SSE_ROUTE } from './runtime/core/server/types'
+import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addTemplate } from '@nuxt/kit'
import { setupDevToolsUI } from './devtools'
import { InjectHydrationPlugin } from './plugins/hydration'
import { LazyLoadHintPlugin } from './plugins/lazy-load'
@@ -55,12 +54,6 @@ export default defineNuxtModule({
priority: 1000,
})
- // core handlers
- addServerHandler({
- route: HINTS_SSE_ROUTE,
- handler: resolver.resolve('./runtime/core/server/sse'),
- })
-
// performances
if (isFeatureEnabled(options, 'webVitals')) {
addPlugin(resolver.resolve('./runtime/web-vitals/plugin.client'))
@@ -70,15 +63,11 @@ export default defineNuxtModule({
if (isFeatureEnabled(options, 'hydration')) {
addPlugin(resolver.resolve('./runtime/hydration/plugin.client'))
addBuildPlugin(InjectHydrationPlugin)
- addServerPlugin(resolver.resolve('./runtime/hydration/nitro.plugin'))
}
// lazy-load suggestions
if (isFeatureEnabled(options, 'lazyLoad')) {
addPlugin(resolver.resolve('./runtime/lazy-load/plugin.client'))
- if (isFeatureDevtoolsEnabled(options, 'lazyLoad')) {
- addServerPlugin(resolver.resolve('./runtime/lazy-load/nitro.plugin'))
- }
nuxt.hook('modules:done', () => {
// hack to ensure the plugins runs after everything else. But before vite:import-analysis
addBuildPlugin(LazyLoadHintPlugin, { client: false })
@@ -98,9 +87,6 @@ export default defineNuxtModule({
if (isFeatureEnabled(options, 'htmlValidate')) {
addPlugin(resolver.resolve('./runtime/html-validate/plugin.client'))
addServerPlugin(resolver.resolve('./runtime/html-validate/nitro.plugin'))
- if (isFeatureDevtoolsEnabled(options, 'htmlValidate')) {
- addServerPlugin(resolver.resolve('./runtime/html-validate/handlers/nitro-handlers.plugin'))
- }
}
nuxt.hook('prepare:types', ({ references }) => {
diff --git a/src/runtime/core/rpc-types.ts b/src/runtime/core/rpc-types.ts
new file mode 100644
index 0000000..fd541b8
--- /dev/null
+++ b/src/runtime/core/rpc-types.ts
@@ -0,0 +1,23 @@
+import type { HydrationMismatchPayload, HydrationMismatchResponse } from '../hydration/types'
+import type { ComponentLazyLoadData } from '../lazy-load/schema'
+import type { HtmlValidateReport } from '../html-validate/types'
+
+export interface HintsClientFunctions {
+ onHydrationMismatch: (mismatch: HydrationMismatchPayload) => void
+ onHydrationCleared: (ids: string[]) => void
+ onLazyLoadReport: (data: ComponentLazyLoadData) => void
+ onLazyLoadCleared: (id: string) => void
+ onHtmlValidateReport: (report: HtmlValidateReport) => void
+ onHtmlValidateDeleted: (id: string) => void
+}
+
+export interface HintsServerFunctions {
+ getHydrationMismatches: () => HydrationMismatchResponse
+ clearHydrationMismatches: (ids: string[]) => void
+ getLazyLoadHints: () => ComponentLazyLoadData[]
+ clearLazyLoadHint: (id: string) => void
+ getHtmlValidateReports: () => HtmlValidateReport[]
+ clearHtmlValidateReport: (id: string) => void
+}
+
+export const RPC_NAMESPACE = 'nuxt-hints'
diff --git a/src/runtime/core/rpc.ts b/src/runtime/core/rpc.ts
new file mode 100644
index 0000000..15edabe
--- /dev/null
+++ b/src/runtime/core/rpc.ts
@@ -0,0 +1,5 @@
+import type { HintsClientFunctions } from './rpc-types'
+
+export function getRPC(): HintsClientFunctions | undefined {
+ return globalThis.__nuxtHintsRpcBroadcast
+}
diff --git a/src/runtime/core/server/sse.ts b/src/runtime/core/server/sse.ts
deleted file mode 100644
index f17a185..0000000
--- a/src/runtime/core/server/sse.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { createEventStream, defineEventHandler } from 'h3'
-import { useNitroApp } from 'nitropack/runtime'
-import type { HintsSseContext } from './types'
-
-export default defineEventHandler((event) => {
- const nitro = useNitroApp()
- const eventStream = createEventStream(event)
-
- const context: HintsSseContext = {
- eventStream,
- unsubscribers: [],
- }
-
- // Allow features to register their SSE event handlers
- nitro.hooks.callHook('hints:sse:setup', context)
-
- eventStream.onClosed(async () => {
- context.unsubscribers.forEach(unsub => unsub())
- await eventStream.close()
- })
-
- return eventStream.send()
-})
diff --git a/src/runtime/core/server/types.ts b/src/runtime/core/server/types.ts
index 6f3e30a..1bc6f72 100644
--- a/src/runtime/core/server/types.ts
+++ b/src/runtime/core/server/types.ts
@@ -1,4 +1,4 @@
-import type { EventHandler, EventStream, H3Event } from 'h3'
+import type { EventHandler, H3Event } from 'h3'
export interface HintsApiContext {
event: H3Event
@@ -6,10 +6,4 @@ export interface HintsApiContext {
handler?: EventHandler
}
-export interface HintsSseContext {
- eventStream: EventStream
- unsubscribers: (() => void)[]
-}
-
export const HINTS_ROUTE = '/__nuxt_hints'
-export const HINTS_SSE_ROUTE = '/__nuxt_hints/sse'
diff --git a/src/runtime/html-validate/api-handlers.ts b/src/runtime/html-validate/api-handlers.ts
new file mode 100644
index 0000000..37986a5
--- /dev/null
+++ b/src/runtime/html-validate/api-handlers.ts
@@ -0,0 +1,48 @@
+import { createError, defineEventHandler, readBody, setResponseStatus } from 'h3'
+import type { HtmlValidateReport } from './types'
+import { getRPC } from '../core/rpc'
+
+export const htmlValidateReports: HtmlValidateReport[] = []
+
+export function storeHtmlValidateReport(report: HtmlValidateReport) {
+ if (htmlValidateReports.some(existing => existing.id === report.id)) {
+ return false
+ }
+
+ htmlValidateReports.push(report)
+ getRPC()?.onHtmlValidateReport(report)
+
+ return true
+}
+
+export function getHtmlValidateReports() {
+ return htmlValidateReports
+}
+
+export function clearHtmlValidateReport(id: string) {
+ const index = htmlValidateReports.findIndex(r => r.id === id)
+ if (index !== -1) {
+ htmlValidateReports.splice(index, 1)
+ }
+ getRPC()?.onHtmlValidateDeleted(id)
+}
+
+export const getHandler = defineEventHandler(() => getHtmlValidateReports())
+
+export const postHandler = defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ if (!body || typeof body.id !== 'string') {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
+ }
+ storeHtmlValidateReport(body)
+ setResponseStatus(event, 201)
+})
+
+export const deleteHandler = defineEventHandler(async (event) => {
+ const id = event.context.params?.id
+ if (typeof id !== 'string') {
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' })
+ }
+ clearHtmlValidateReport(id)
+ setResponseStatus(event, 204)
+})
diff --git a/src/runtime/html-validate/handlers/delete.ts b/src/runtime/html-validate/handlers/delete.ts
deleted file mode 100644
index e45439b..0000000
--- a/src/runtime/html-validate/handlers/delete.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { createError, defineEventHandler, setResponseStatus } from 'h3'
-import { storage } from '../storage'
-import { useNitroApp } from 'nitropack/runtime'
-
-export const deleteHandler = defineEventHandler(async (event) => {
- const nitro = useNitroApp()
- const id = event.context.params?.id
- if (typeof id === 'string') {
- await storage.removeItem(id)
- setResponseStatus(event, 204)
- nitro.hooks.callHook('hints:html-validate:deleted', id)
- return {}
- }
- throw createError({
- statusCode: 404,
- statusMessage: 'Not Found',
- })
-})
diff --git a/src/runtime/html-validate/handlers/get.ts b/src/runtime/html-validate/handlers/get.ts
deleted file mode 100644
index 2e36fd2..0000000
--- a/src/runtime/html-validate/handlers/get.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { defineEventHandler } from 'h3'
-import { storage } from '../storage'
-import type { HtmlValidateReport } from '../types'
-
-export const getAllHandler = defineEventHandler(() => {
- return storage.getKeys()
- .then(keys => Promise.all(keys.map(key => storage.getItem(key))))
- .then(items => items.filter((item): item is HtmlValidateReport => item !== null))
-})
diff --git a/src/runtime/html-validate/handlers/nitro-handlers.plugin.ts b/src/runtime/html-validate/handlers/nitro-handlers.plugin.ts
deleted file mode 100644
index 0f6d42b..0000000
--- a/src/runtime/html-validate/handlers/nitro-handlers.plugin.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { NitroAppPlugin } from 'nitropack/types'
-import { join } from 'pathe'
-import { HTMLVALIDATE_ROUTE } from '../utils'
-import { getAllHandler } from './get'
-import { deleteHandler } from './delete'
-
-export default function (nitro) {
- nitro.router.add(
- HTMLVALIDATE_ROUTE,
- getAllHandler,
- 'get',
- )
-
- nitro.router.add(
- join(HTMLVALIDATE_ROUTE, ':id'),
- deleteHandler,
- 'delete',
- )
-
- // sse
- nitro.hooks.hook('hints:sse:setup', (context) => {
- context.unsubscribers.push(
- nitro.hooks.hook('hints:html-validate:report', (report) => {
- context.eventStream.push(
- {
- data: JSON.stringify(report),
- event: 'hints:html-validate:report',
- },
- )
- }),
- nitro.hooks.hook('hints:html-validate:deleted', (id) => {
- context.eventStream.push(
- {
- data: id,
- event: 'hints:html-validate:deleted',
- },
- )
- }),
- )
- })
-}
diff --git a/src/runtime/html-validate/nitro.plugin.ts b/src/runtime/html-validate/nitro.plugin.ts
index 4c2a93f..bb391be 100644
--- a/src/runtime/html-validate/nitro.plugin.ts
+++ b/src/runtime/html-validate/nitro.plugin.ts
@@ -1,7 +1,7 @@
import type { NitroAppPlugin } from 'nitropack/types'
import { HtmlValidate, type ConfigData, type RuleConfig } from 'html-validate'
import { addBeforeBodyEndTag } from './utils'
-import { storage } from './storage'
+import { storeHtmlValidateReport } from './api-handlers'
import { randomUUID } from 'crypto'
import { stringify } from 'devalue'
import type { HtmlValidateReport } from './types'
@@ -10,6 +10,8 @@ import html from 'prettier/parser-html'
import { getFeatureOptions } from '../core/features'
import { defu } from 'defu'
+const HTML_VALIDATE_REPORT_HOOK = 'hints:html-validate:report' as const
+
const DEFAULT_EXTENDS = [
'html-validate:standard',
'html-validate:document',
@@ -37,6 +39,8 @@ export default function (nitro) {
const validator = new HtmlValidate(opts)
+ nitro.hooks.hook(HTML_VALIDATE_REPORT_HOOK, storeHtmlValidateReport)
+
nitro.hooks.hook('render:response', async (response, { event }) => {
if (typeof response.body === 'string' && (response.headers?.['Content-Type'] || response.headers?.['content-type'])?.includes('html')) {
const formattedBody = await format(response.body, { plugins: [html], parser: 'html' })
@@ -50,12 +54,13 @@ export default function (nitro) {
html: formattedBody,
results: results.results,
}
- storage.setItem(id, data)
response.body = addBeforeBodyEndTag(
response.body,
``,
)
- nitro.hooks.callHook('hints:html-validate:report', data)
+ nitro.hooks.callHook(HTML_VALIDATE_REPORT_HOOK, data).catch((error) => {
+ nitro.captureError(error instanceof Error ? error : new Error(String(error)), { event })
+ })
}
}
})
diff --git a/src/runtime/html-validate/storage.ts b/src/runtime/html-validate/storage.ts
deleted file mode 100644
index 0225eca..0000000
--- a/src/runtime/html-validate/storage.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { createStorage } from 'unstorage'
-
-export const storage = createStorage()
diff --git a/src/runtime/hydration/handlers.ts b/src/runtime/hydration/handlers.ts
new file mode 100644
index 0000000..4ce9ecb
--- /dev/null
+++ b/src/runtime/hydration/handlers.ts
@@ -0,0 +1,66 @@
+import { createError, defineEventHandler, readBody, setResponseStatus } from 'h3'
+import type { HydrationMismatchPayload } from './types'
+import { getRPC } from '../core/rpc'
+
+export const hydrationMismatches: HydrationMismatchPayload[] = []
+
+export function getHydrationMismatches() {
+ return { mismatches: hydrationMismatches }
+}
+
+export function clearHydrationMismatches(ids: string[]) {
+ for (const id of ids) {
+ const index = hydrationMismatches.findIndex(m => m.id === id)
+ if (index !== -1) {
+ hydrationMismatches.splice(index, 1)
+ }
+ }
+ getRPC()?.onHydrationCleared(ids)
+}
+
+export const getHandler = defineEventHandler(() => getHydrationMismatches())
+
+export const postHandler = defineEventHandler(async (event) => {
+ const body = await readBody>(event)
+ assertPayload(body)
+ const payload: HydrationMismatchPayload = {
+ id: crypto.randomUUID(),
+ htmlPreHydration: body.htmlPreHydration,
+ htmlPostHydration: body.htmlPostHydration,
+ componentName: body.componentName,
+ fileLocation: body.fileLocation,
+ }
+ hydrationMismatches.push(payload)
+ if (hydrationMismatches.length > 20) {
+ const evicted = hydrationMismatches.shift()
+ if (evicted) {
+ getRPC()?.onHydrationCleared([evicted.id])
+ }
+ }
+ getRPC()?.onHydrationMismatch(payload)
+ setResponseStatus(event, 201)
+ return payload
+})
+
+export const deleteHandler = defineEventHandler(async (event) => {
+ const body = await readBody<{ id: string[] }>(event)
+ if (!body || !Array.isArray(body.id)) {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
+ }
+ clearHydrationMismatches(body.id)
+ setResponseStatus(event, 204)
+})
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function assertPayload(body: any): asserts body is Omit {
+ if (
+ !body
+ || typeof body !== 'object'
+ || (body.htmlPreHydration !== undefined && typeof body.htmlPreHydration !== 'string')
+ || (body.htmlPostHydration !== undefined && typeof body.htmlPostHydration !== 'string')
+ || typeof body.componentName !== 'string'
+ || typeof body.fileLocation !== 'string'
+ ) {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
+ }
+}
diff --git a/src/runtime/hydration/nitro.plugin.ts b/src/runtime/hydration/nitro.plugin.ts
deleted file mode 100644
index b4e8737..0000000
--- a/src/runtime/hydration/nitro.plugin.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { createError, defineEventHandler, readBody, setResponseStatus } from 'h3'
-import type { NitroApp } from 'nitropack/types'
-import type { HydrationMismatchPayload } from './types'
-import type { HintsSseContext } from '../core/server/types'
-
-const hydrationMismatches: HydrationMismatchPayload[] = []
-
-export default function (nitroApp: NitroApp) {
- const getHandler = defineEventHandler(() => {
- return {
- mismatches: hydrationMismatches,
- }
- })
-
- const postHandler = defineEventHandler(async (event) => {
- const body = await readBody>(event)
- assertPayload(body)
- const payload: HydrationMismatchPayload = {
- id: crypto.randomUUID(),
- htmlPreHydration: body.htmlPreHydration,
- htmlPostHydration: body.htmlPostHydration,
- componentName: body.componentName,
- fileLocation: body.fileLocation,
- }
- hydrationMismatches.push(payload)
- if (hydrationMismatches.length > 20) {
- hydrationMismatches.shift()
- }
- nitroApp.hooks.callHook('hints:hydration:mismatch', payload)
- setResponseStatus(event, 201)
- return payload
- })
-
- const deleteHandler = defineEventHandler(async (event) => {
- const body = await readBody<{ id: string[] }>(event)
-
- if (!body || !Array.isArray(body.id)) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
- }
-
- for (const id of body.id) {
- const index = hydrationMismatches.findIndex(m => m.id === id)
- if (index !== -1) {
- hydrationMismatches.splice(index, 1)
- }
- }
-
- nitroApp.hooks.callHook('hints:hydration:cleared', { id: body.id })
- setResponseStatus(event, 204)
- })
-
- nitroApp.router.add('/__nuxt_hints/hydration', getHandler, 'get')
- nitroApp.router.add('/__nuxt_hints/hydration', postHandler, 'post')
- nitroApp.router.add('/__nuxt_hints/hydration', deleteHandler, 'delete')
-
- // Register SSE event handlers for hydration
- nitroApp.hooks.hook('hints:sse:setup', (context: HintsSseContext) => {
- context.unsubscribers.push(
- nitroApp.hooks.hook('hints:hydration:mismatch', (mismatch) => {
- context.eventStream.push({
- data: JSON.stringify(mismatch),
- event: 'hints:hydration:mismatch',
- })
- }),
- nitroApp.hooks.hook('hints:hydration:cleared', (payload) => {
- context.eventStream.push({
- data: JSON.stringify(payload.id),
- event: 'hints:hydration:cleared',
- })
- }),
- )
- })
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- function assertPayload(body: any): asserts body is Omit {
- if (
- typeof body !== 'object'
- || (body.htmlPreHydration !== undefined && typeof body.htmlPreHydration !== 'string')
- || (body.htmlPostHydration !== undefined && typeof body.htmlPostHydration !== 'string')
- || typeof body.componentName !== 'string'
- || typeof body.fileLocation !== 'string'
- ) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid payload' })
- }
- }
-}
diff --git a/src/runtime/hydration/types.ts b/src/runtime/hydration/types.ts
index a422268..9e53e18 100644
--- a/src/runtime/hydration/types.ts
+++ b/src/runtime/hydration/types.ts
@@ -1,4 +1,3 @@
-import type { EventStreamMessage } from 'h3'
import type { ComponentInternalInstance, VNode } from 'vue'
export interface HydrationMismatchPayload {
@@ -14,24 +13,6 @@ export interface LocalHydrationMismatch extends HydrationMismatchPayload {
vnode: VNode
}
-// prefer interface for extensibility
export interface HydrationMismatchResponse {
mismatches: HydrationMismatchPayload[]
}
-
-export interface HydrationDeleteSSE extends EventStreamMessage {
- event: 'hydration:cleared'
- // array of ids
- data: string
-}
-
-export interface HydrationNewSSE extends EventStreamMessage {
- event: 'hydration:mismatch'
- /**
- * Stringified HydrationMismatchPayload
- * @see HydrationMismatchPayload
- */
- data: string
-}
-
-export type HydrationSSEPayload = HydrationDeleteSSE | HydrationNewSSE
diff --git a/src/runtime/hydration/utils.ts b/src/runtime/hydration/utils.ts
index 91d8f26..cf382df 100644
--- a/src/runtime/hydration/utils.ts
+++ b/src/runtime/hydration/utils.ts
@@ -1,10 +1,9 @@
-import { HINTS_ROUTE, HINTS_SSE_ROUTE } from '../core/server/types'
+import { HINTS_ROUTE } from '../core/server/types'
import { createHintsLogger } from '../logger'
export const logger = createHintsLogger('hydration')
export const HYDRATION_ROUTE = `${HINTS_ROUTE}/hydration`
-export const HYDRATION_SSE_ROUTE = HINTS_SSE_ROUTE
export function formatHTML(html: string | undefined): string {
if (!html) return ''
diff --git a/src/runtime/lazy-load/handlers.ts b/src/runtime/lazy-load/handlers.ts
new file mode 100644
index 0000000..aed46a3
--- /dev/null
+++ b/src/runtime/lazy-load/handlers.ts
@@ -0,0 +1,57 @@
+import { createError, defineEventHandler, readBody, setResponseStatus } from 'h3'
+import type { ComponentLazyLoadData } from './schema'
+import { ComponentLazyLoadDataSchema } from './schema'
+import { parse, ValiError } from 'valibot'
+import { getRPC } from '../core/rpc'
+
+export const lazyLoadData: ComponentLazyLoadData[] = []
+
+export function getLazyLoadHints() {
+ return lazyLoadData
+}
+
+export function clearLazyLoadHint(id: string) {
+ const next = lazyLoadData.filter(item => item.id !== id)
+ lazyLoadData.length = 0
+ lazyLoadData.push(...next)
+ getRPC()?.onLazyLoadCleared(id)
+}
+
+export const getHandler = defineEventHandler(() => getLazyLoadHints())
+
+export const postHandler = defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ let parsed: ComponentLazyLoadData
+ try {
+ parsed = parse(ComponentLazyLoadDataSchema, body)
+ }
+ catch (error) {
+ if (error instanceof ValiError) {
+ setResponseStatus(event, 400)
+ return { error: 'Validation failed', message: error.message }
+ }
+ throw error
+ }
+ const index = lazyLoadData.findIndex(item => item.id === parsed.id)
+ if (index === -1) {
+ lazyLoadData.push(parsed)
+ getRPC()?.onLazyLoadReport(parsed)
+ }
+ setResponseStatus(event, 201)
+})
+
+export const deleteHandler = defineEventHandler(async (event) => {
+ const id = event.context.params?.id
+ if (!id) {
+ throw createError({ statusCode: 400, message: 'ID is required' })
+ }
+ const hasEntry = lazyLoadData.some(item => item.id === id)
+ if (!hasEntry) {
+ throw createError({ statusCode: 404, message: 'Entry not found' })
+ }
+ const next = lazyLoadData.filter(item => item.id !== id)
+ lazyLoadData.length = 0
+ lazyLoadData.push(...next)
+ getRPC()?.onLazyLoadCleared(id)
+ setResponseStatus(event, 204)
+})
diff --git a/src/runtime/lazy-load/nitro.plugin.ts b/src/runtime/lazy-load/nitro.plugin.ts
deleted file mode 100644
index d214b75..0000000
--- a/src/runtime/lazy-load/nitro.plugin.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { createError, defineEventHandler, setResponseStatus, readBody } from 'h3'
-import type { NitroApp } from 'nitropack/types'
-import type { ComponentLazyLoadData } from './schema'
-import { ComponentLazyLoadDataSchema } from './schema'
-import { parse, ValiError } from 'valibot'
-import type { HintsSseContext } from '../core/server/types'
-import { LAZY_LOAD_ROUTE } from './utils'
-
-const data: ComponentLazyLoadData[] = []
-
-export default function (nitroApp: NitroApp) {
- const getHandler = defineEventHandler(() => {
- return data
- })
-
- const postHandler = defineEventHandler(async (event) => {
- const body = await readBody(event)
- let parsed: ComponentLazyLoadData
- try {
- parsed = parse(ComponentLazyLoadDataSchema, body)
- }
- catch (error) {
- if (error instanceof ValiError) {
- setResponseStatus(event, 400)
- return { error: 'Validation failed', message: error.message }
- }
- throw error
- }
- data.push(parsed)
- nitroApp.hooks.callHook('hints:lazy-load:report', parsed)
- setResponseStatus(event, 201)
- })
-
- const deleteHandler = defineEventHandler(async (event) => {
- const id = event.context.params?.id
- if (!id) {
- throw createError({ statusCode: 400, message: 'ID is required' })
- }
-
- const index = data.findIndex(item => item.id === id)
- if (index !== -1) {
- data.splice(index, 1)
- }
- else {
- throw createError({ statusCode: 404, message: 'Entry not found' })
- }
- nitroApp.hooks.callHook('hints:lazy-load:cleared', { id })
- setResponseStatus(event, 204)
- })
-
- nitroApp.router.add(LAZY_LOAD_ROUTE, getHandler, 'get')
- nitroApp.router.add(LAZY_LOAD_ROUTE, postHandler, 'post')
- nitroApp.router.add(`${LAZY_LOAD_ROUTE}/:id`, deleteHandler, 'delete')
-
- nitroApp.hooks.hook('hints:sse:setup', (context: HintsSseContext) => {
- context.unsubscribers.push(
- nitroApp.hooks.hook('hints:lazy-load:report', (payload) => {
- context.eventStream.push({
- data: JSON.stringify(payload),
- event: 'hints:lazy-load:report',
- })
- }),
- nitroApp.hooks.hook('hints:lazy-load:cleared', (payload) => {
- context.eventStream.push({
- data: JSON.stringify(payload.id),
- event: 'hints:lazy-load:cleared',
- })
- }),
- )
- })
-}
diff --git a/src/runtime/nitro.d.ts b/src/runtime/nitro.d.ts
new file mode 100644
index 0000000..fa1b760
--- /dev/null
+++ b/src/runtime/nitro.d.ts
@@ -0,0 +1,7 @@
+import type { HtmlValidateReport } from './html-validate/types'
+
+declare module 'nitropack/types' {
+ interface NitroRuntimeHooks {
+ 'hints:html-validate:report': (report: HtmlValidateReport) => void
+ }
+}
diff --git a/src/runtime/types.d.ts b/src/runtime/types.d.ts
index 96d1377..c0ffda1 100644
--- a/src/runtime/types.d.ts
+++ b/src/runtime/types.d.ts
@@ -1,10 +1,13 @@
import type { VNode, Ref } from 'vue'
import type { LCPMetricWithAttribution, INPMetricWithAttribution, CLSMetricWithAttribution } from 'web-vitals/attribution'
-import type { HydrationMismatchPayload, LocalHydrationMismatch } from './hydration/types'
+import type { LocalHydrationMismatch } from './hydration/types'
import type { DirectImportInfo, LazyHydrationState } from './lazy-load/composables'
import type { Features } from './core/types'
+import type { HintsClientFunctions } from './core/rpc-types'
declare global {
+ var __nuxtHintsRpcBroadcast: HintsClientFunctions | undefined
+
interface Window {
__hints_TPC_start_time: number
__hints_TPC_saveTime: (script: HTMLScriptElement, startTime?: number) => void
@@ -64,23 +67,4 @@ declare module '#app' {
}
}
-declare module 'nitropack' {
- interface NitroRuntimeHooks {
- // Core hints hooks
- 'hints:sse:setup': (context: import('./core/server/types').HintsSseContext) => void
-
- // html-validate hooks
- 'hints:html-validate:report': (report: import('./html-validate/types').HtmlValidateReport) => void
- 'hints:html-validate:deleted': (id: string) => void
-
- // Hydration hooks
- 'hints:hydration:mismatch': (payload: HydrationMismatchPayload) => void
- 'hints:hydration:cleared': (payload: { id: string[] }) => void
-
- // Lazy-load hooks
- 'hints:lazy-load:report': (payload: import('./lazy-load/schema').ComponentLazyLoadData) => void
- 'hints:lazy-load:cleared': (payload: { id: string }) => void
- }
-}
-
export {}
diff --git a/test/unit/core/sse.test.ts b/test/unit/core/sse.test.ts
deleted file mode 100644
index 71b79c9..0000000
--- a/test/unit/core/sse.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { describe, expect, it, vi, beforeEach, afterAll, beforeAll } from 'vitest'
-import { createApp, toWebHandler, toNodeListener } from 'h3'
-import sseEndpoint from '../../../src/runtime/core/server/sse'
-import type { Hookable } from 'hookable'
-import { createHooks } from 'hookable'
-import { useNitroApp } from 'nitropack/runtime'
-import type { NitroRuntimeHooks } from 'nitropack/types'
-import { createServer } from 'node:http'
-
-import {
- getRandomPort,
-} from 'get-port-please'
-
-vi.mock('nitropack/runtime', async () => ({
- useNitroApp: vi.fn(),
-}))
-
-const app = createApp()
-
-app.use('/', sseEndpoint)
-const handler = toWebHandler(app)
-const server = createServer(toNodeListener(app))
-
-describe('sseEndpoint', () => {
- let hooks: Hookable
- let port: number
-
- beforeAll(async () => {
- port = await getRandomPort()
- server.listen(port, () => {
- console.log(`Test server running on http://localhost:${port}`)
- })
- })
-
- beforeEach(() => {
- hooks = createHooks()
- vi.mocked(useNitroApp).mockReturnValue({ hooks } as unknown as ReturnType)
- })
-
- afterAll(() => {
- server.close()
- })
-
- it('should call registered SSE handlers on setup', async () => {
- const mockHandler = vi.fn()
-
- hooks.hook('hints:sse:setup', mockHandler)
- const response = await handler(new Request(new URL('/', `http://localhost:${port}`)))
- expect(mockHandler).toHaveBeenCalled()
- expect(response.status).toBe(200)
- expect(response.headers.get('Content-Type')).toBe('text/event-stream')
- })
-})
diff --git a/test/unit/html-validate/nitro-plugin.test.ts b/test/unit/html-validate/nitro-plugin.test.ts
new file mode 100644
index 0000000..4d86b82
--- /dev/null
+++ b/test/unit/html-validate/nitro-plugin.test.ts
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import plugin from '../../../src/runtime/html-validate/nitro.plugin'
+import { htmlValidateReports } from '../../../src/runtime/html-validate/api-handlers'
+
+const reportResults = [
+ {
+ filePath: '/demo',
+ messages: [
+ {
+ ruleId: 'no-unknown-elements',
+ message: 'Unknown element',
+ line: 1,
+ column: 1,
+ offset: 0,
+ size: 1,
+ selector: 'body',
+ },
+ ],
+ errorCount: 1,
+ warningCount: 0,
+ source: '',
+ },
+]
+
+const rpcMock = vi.hoisted(() => ({
+ onHtmlValidateReport: vi.fn(),
+ onHtmlValidateDeleted: vi.fn(),
+}))
+
+vi.mock('../../../src/runtime/core/rpc', () => ({
+ getRPC: () => rpcMock,
+}))
+
+vi.mock('crypto', () => ({
+ randomUUID: () => 'report-1',
+}))
+
+vi.mock('devalue', () => ({
+ stringify: () => '{"id":"report-1"}',
+}))
+
+vi.mock('prettier/standalone', () => ({
+ format: vi.fn(async (value: string) => value),
+}))
+
+vi.mock('prettier/parser-html', () => ({
+ default: {},
+}))
+
+vi.mock('html-validate', () => ({
+ HtmlValidate: class {
+ async validateString() {
+ return {
+ errorCount: 1,
+ results: reportResults,
+ }
+ }
+ },
+}))
+
+vi.mock('../../../src/runtime/core/features', () => ({
+ getFeatureOptions: () => undefined,
+}))
+
+vi.mock('../../../src/runtime/html-validate/utils', () => ({
+ addBeforeBodyEndTag: (body: string, tag: string) => body.replace('