Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 1 addition & 85 deletions client/app/app.vue
Original file line number Diff line number Diff line change
@@ -1,91 +1,7 @@
<script setup lang="ts">
import { useDevtoolsClient, onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
import type { HintsClientFunctions } from '../../src/runtime/core/rpc-types'
import { RPC_NAMESPACE } from '../../src/runtime/core/rpc-types'
import type { HydrationMismatchPayload, HydrationMismatchResponse, LocalHydrationMismatch } from '../../src/runtime/hydration/types'
import type { ComponentLazyLoadData } from '../../src/runtime/lazy-load/schema'
import { ComponentLazyLoadDataSchema } from '../../src/runtime/lazy-load/schema'
import type { HtmlValidateReport } from '../../src/runtime/html-validate/types'
import { parse } from 'valibot'
import { HYDRATION_ROUTE, LAZY_LOAD_ROUTE, HTML_VALIDATE_ROUTE } from './utils/routes'
import { useDevtoolsClient } from '@nuxt/devtools-kit/iframe-client'

const client = useDevtoolsClient()

const hydrationMismatches = ref<(HydrationMismatchPayload | LocalHydrationMismatch)[]>([])
const lazyLoadHints = ref<ComponentLazyLoadData[]>([])
const htmlValidateReports = ref<HtmlValidateReport[]>([])

const nuxtApp = useNuxtApp()
nuxtApp.provide('hydrationMismatches', hydrationMismatches)
nuxtApp.provide('lazyLoadHints', lazyLoadHints)
nuxtApp.provide('htmlValidateReports', htmlValidateReports)

onDevtoolsClientConnected((client) => {
// Hydration: seed from host payload and fetch from server
if (useHintsFeature('hydration')) {
hydrationMismatches.value = [...client.host.nuxt.payload.__hints.hydration]
$fetch<HydrationMismatchResponse>(new URL(HYDRATION_ROUTE, window.location.origin).href).then((data) => {
hydrationMismatches.value = [
...hydrationMismatches.value,
...data.mismatches.filter(m => !hydrationMismatches.value.some(existing => existing.id === m.id)),
]
})
}

// Lazy load: fetch from server
if (useHintsFeature('lazyLoad')) {
$fetch<ComponentLazyLoadData[]>(new URL(LAZY_LOAD_ROUTE, window.location.origin).href).then((data) => {
lazyLoadHints.value = [
...lazyLoadHints.value,
...(data ?? []).filter(d => !lazyLoadHints.value.some(existing => existing.id === d.id)),
]
})
}

// HTML validate: fetch from server
if (useHintsFeature('htmlValidate')) {
$fetch<HtmlValidateReport[]>(new URL(HTML_VALIDATE_ROUTE, window.location.origin).href).then((data) => {
htmlValidateReports.value = [
...htmlValidateReports.value,
...(data ?? []).filter(d => !htmlValidateReports.value.some(existing => existing.id === d.id)),
]
})
}

// Register client RPC functions for real-time push notifications
client.devtools.extendClientRpc<Record<string, unknown>, HintsClientFunctions>(RPC_NAMESPACE, {
onHydrationMismatch(mismatch: HydrationMismatchPayload) {
if (!hydrationMismatches.value.some(existing => existing.id === mismatch.id)) {
hydrationMismatches.value.push(mismatch)
}
},
onHydrationCleared(ids: string[]) {
hydrationMismatches.value = hydrationMismatches.value.filter(m => !ids.includes(m.id))
},
onLazyLoadReport(data: ComponentLazyLoadData) {
try {
const validated = parse(ComponentLazyLoadDataSchema, data)
if (!lazyLoadHints.value.some(existing => existing.id === validated.id)) {
lazyLoadHints.value.push(validated)
}
}
catch {
console.warn('[hints] Ignoring malformed lazy-load report', data)
}
},
onLazyLoadCleared(id: string) {
lazyLoadHints.value = lazyLoadHints.value.filter(entry => entry.id !== id)
},
onHtmlValidateReport(report: HtmlValidateReport) {
if (!htmlValidateReports.value.some(existing => existing.id === report.id)) {
htmlValidateReports.value = [...htmlValidateReports.value, report]
}
},
onHtmlValidateDeleted(id: string) {
htmlValidateReports.value = htmlValidateReports.value.filter(report => report.id !== id)
},
})
})
</script>

<template>
Expand Down
19 changes: 19 additions & 0 deletions client/app/plugins/0.sse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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,
},
}
})
44 changes: 44 additions & 0 deletions client/app/plugins/html-validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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<HtmlValidateReport[]>(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,
},
}
})
47 changes: 47 additions & 0 deletions client/app/plugins/hydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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<HydrationMismatchResponse>(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,
},
}
})
57 changes: 57 additions & 0 deletions client/app/plugins/lazy-load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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<ComponentLazyLoadData[]>(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,
},
}
})
1 change: 1 addition & 0 deletions client/app/utils/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

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`
9 changes: 1 addition & 8 deletions src/devtools.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { addCustomTab, extendServerRpc, onDevToolsInitialized } from '@nuxt/devtools-kit'
import { addCustomTab } 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 } from './runtime/core/rpc-types'
import { RPC_NAMESPACE } from './runtime/core/rpc-types'

const DEVTOOLS_UI_ROUTE = '/__nuxt-hints'
const DEVTOOLS_UI_LOCAL_PORT = 3300
Expand Down Expand Up @@ -43,9 +41,4 @@ export function setupDevToolsUI(nuxt: Nuxt, resolver: Resolver) {
src: DEVTOOLS_UI_ROUTE,
},
}, nuxt)

onDevToolsInitialized(() => {
const rpc = extendServerRpc<HintsClientFunctions>(RPC_NAMESPACE, {})
globalThis.__nuxtHintsRpcBroadcast = rpc.broadcast
}, nuxt)
}
10 changes: 8 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addTemplate } from '@nuxt/kit'
import { defineNuxtModule, addPlugin, createResolver, addBuildPlugin, addComponent, addServerPlugin, addServerHandler, addTemplate } from '@nuxt/kit'
import { HINTS_SSE_ROUTE } from './runtime/core/server/types'
import { setupDevToolsUI } from './devtools'
import { InjectHydrationPlugin } from './plugins/hydration'
import { LazyLoadHintPlugin } from './plugins/lazy-load'
Expand Down Expand Up @@ -54,6 +55,12 @@ export default defineNuxtModule<ModuleOptions>({
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'))
Expand Down Expand Up @@ -105,7 +112,6 @@ export default defineNuxtModule<ModuleOptions>({
if (options.devtools) {
setupDevToolsUI(nuxt, resolver)
addPlugin(resolver.resolve('./runtime/core/plugins/vue-tracer-state.client'))
addServerPlugin(resolver.resolve('./runtime/core/server/rpc-bridge'))
}

nuxt.options.build.transpile.push(moduleName)
Expand Down
18 changes: 0 additions & 18 deletions src/runtime/core/rpc-types.ts

This file was deleted.

32 changes: 0 additions & 32 deletions src/runtime/core/server/rpc-bridge.ts

This file was deleted.

23 changes: 23 additions & 0 deletions src/runtime/core/server/sse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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()
})
Loading
Loading