@nuxt/devtools
Version:
The Nuxt DevTools gives you insights and transparency about your Nuxt App.
371 lines (370 loc) • 11.3 kB
JavaScript
import { useAppConfig, useRuntimeConfig } from "#imports";
import { NuxtDevtoolsFrame, NuxtDevtoolsInspectPanel } from "@nuxt/devtools/webcomponents";
import { setIframeServerContext } from "@vue/devtools-kit";
import { createHooks } from "hookable";
import { debounce } from "perfect-debounce";
import { events as inspectorEvents, hasData as inspectorHasData, state as inspectorState } from "vite-plugin-vue-tracer/client/overlay";
import { computed, markRaw, nextTick, reactive, ref, shallowReactive, shallowRef, toRef, watch } from "vue";
import { initTimelineMetrics } from "../../function-metrics-helpers.js";
import { settings } from "../../settings.js";
import { popupWindow, state } from "./state.js";
const clientRef = shallowRef();
export { clientRef as client };
export async function setupDevToolsClient({
nuxt,
clientHooks,
timeMetric,
router
}) {
let iframe;
let inspector;
const colorMode = useClientColorMode();
const timeline = initTimelineMetrics();
const client = shallowReactive({
nuxt: markRaw(nuxt),
hooks: createHooks(),
inspector: getInspectorInstance(),
getIframe,
syncClient,
devtools: {
toggle() {
if (state.value.open)
client.devtools.close();
else
client.devtools.open();
},
close() {
if (!state.value.open)
return;
state.value.open = false;
if (popupWindow.value) {
try {
popupWindow.value.close();
} catch {
}
popupWindow.value = null;
}
},
open() {
if (state.value.open)
return;
state.value.open = true;
},
async navigate(path) {
if (!state.value.open)
await client.devtools.open();
await client.hooks.callHook("host:action:navigate", path);
},
async reload() {
await client.hooks.callHook("host:action:reload");
}
},
app: {
appConfig: useAppConfig(),
reload() {
location.reload();
},
navigate(path, hard = false) {
if (hard)
location.href = path;
else
router.push(path);
},
colorMode,
frameState: state,
$fetch: globalThis.$fetch
},
metrics: {
clientPlugins: () => window.__NUXT_DEVTOOLS_PLUGINS_METRIC__,
clientHooks: () => Object.values(clientHooks),
clientTimeline: () => timeline,
loading: () => timeMetric
},
revision: ref(0)
});
window.__NUXT_DEVTOOLS_HOST__ = client;
function syncClient() {
if (!client.inspector)
client.inspector = getInspectorInstance();
try {
iframe?.contentWindow?.__NUXT_DEVTOOLS_VIEW__?.setClient(client);
} catch {
}
return client;
}
function getIframe() {
if (!iframe) {
const runtimeConfig = useRuntimeConfig();
const CLIENT_BASE = "/__nuxt_devtools__/client";
const CLIENT_PATH = `${runtimeConfig.app.baseURL.replace(CLIENT_BASE, "/")}${CLIENT_BASE}`.replace(/\/+/g, "/");
const initialUrl = CLIENT_PATH + state.value.route;
iframe = document.createElement("iframe");
for (const [key, value] of Object.entries(runtimeConfig.app.devtools?.iframeProps || {}))
iframe.setAttribute(key, String(value));
iframe.id = "nuxt-devtools-iframe";
iframe.src = initialUrl;
iframe.onload = async () => {
try {
setIframeServerContext(iframe);
await waitForClientInjection();
client.syncClient();
} catch (e) {
console.error("Nuxt DevTools client injection failed");
console.error(e);
}
};
}
return iframe;
}
function waitForClientInjection(retry = 20, timeout = 300) {
let lastError;
const test = () => {
try {
return !!iframe?.contentWindow?.__NUXT_DEVTOOLS_VIEW__;
} catch (e) {
lastError = e;
}
return false;
};
if (test())
return;
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if (test()) {
clearInterval(interval);
resolve();
} else if (retry-- <= 0) {
clearInterval(interval);
reject(lastError);
}
}, timeout);
});
}
function getInspectorInstance() {
if (inspector)
return inspector;
const props = reactive({
mouse: { x: 0, y: 0 },
hasParent: false,
matched: void 0
});
const component = new NuxtDevtoolsInspectPanel(reactive({ props }));
document.body.appendChild(component);
Object.assign(component.style, {
zIndex: 999999,
position: "fixed"
});
component.addEventListener("close", () => {
props.matched = void 0;
inspectorState.isEnabled = false;
inspectorState.isVisible = false;
});
component.addEventListener("selectParent", () => {
const parent = inspectorState.main?.getParent();
if (parent) {
inspectorState.main = parent;
props.matched = parent;
nextTick(() => {
props.hasParent = !!inspectorState.main?.getParent();
});
}
});
component.addEventListener("openInEditor", async (e) => {
const url = e?.detail?.[0];
if (url)
await client.hooks.callHook("host:inspector:click", url);
});
inspectorEvents.on("hover", () => {
inspectorState.isFocused = false;
props.hasParent = !!inspectorState.main?.getParent();
});
inspectorEvents.on("disabled", () => {
inspectorState.isVisible = false;
client?.hooks.callHook("host:inspector:close");
});
inspectorEvents.on("enabled", () => {
inspectorState.isVisible = true;
inspectorState.isEnabled = true;
});
inspectorEvents.on("click", async (info, e) => {
inspectorState.isEnabled = false;
inspectorState.isFocused = true;
inspectorState.isVisible = true;
props.matched = info;
props.mouse = { x: e.clientX, y: e.clientY };
});
const isAvailable = ref(inspectorHasData());
if (!isAvailable.value) {
inspectorEvents.on("hover", async () => {
isAvailable.value = inspectorHasData();
});
}
return inspector = markRaw({
isAvailable,
isEnabled: toRef(inspectorState, "isVisible"),
enable: () => {
inspectorState.isVisible = true;
inspectorState.isEnabled = true;
},
disable: () => {
inspectorState.isVisible = false;
inspectorState.isEnabled = false;
},
toggle: () => {
inspectorState.isEnabled = !inspectorState.isEnabled;
inspectorState.isVisible = inspectorState.isEnabled;
}
});
}
setupRouteTracking(timeline, router);
setupReactivity(client, router, timeline);
clientRef.value = client;
const documentPictureInPicture = window.documentPictureInPicture;
if (documentPictureInPicture?.requestWindow) {
client.devtools.popup = async () => {
const iframe2 = getIframe();
if (!iframe2)
return;
const pip = popupWindow.value = await documentPictureInPicture.requestWindow({
width: Math.round(window.innerWidth * state.value.width / 100),
height: Math.round(window.innerHeight * state.value.height / 100)
});
const style = pip.document.createElement("style");
style.innerHTML = `
body {
margin: 0;
padding: 0;
}
iframe {
width: 100vw;
height: 100vh;
border: none;
outline: none;
}
`;
pip.__NUXT_DEVTOOLS_DISABLE__ = true;
pip.__NUXT_DEVTOOLS_IS_POPUP__ = true;
pip.__NUXT__ = window.parent?.__NUXT__ || window.__NUXT__;
pip.document.title = "Nuxt DevTools";
pip.document.head.appendChild(style);
pip.document.body.appendChild(iframe2);
pip.addEventListener("resize", () => {
state.value.width = Math.round(pip.innerWidth / window.innerWidth * 100);
state.value.height = Math.round(pip.innerHeight / window.innerHeight * 100);
});
pip.addEventListener("pagehide", () => {
popupWindow.value = null;
pip.close();
});
};
}
const holder = document.createElement("div");
holder.id = "nuxt-devtools-container";
holder.setAttribute("data-v-inspector-ignore", "true");
document.body.appendChild(holder);
window.addEventListener("keydown", (e) => {
if (e.code === "KeyD" && e.altKey && e.shiftKey)
client.devtools.toggle();
});
const frame = new NuxtDevtoolsFrame(reactive({
client,
settings,
state,
popupWindow
}));
holder.appendChild(frame);
}
export function useClientColorMode() {
const explicitColor = ref();
const systemColor = ref();
const elements = [
document.documentElement,
document.body
];
const ob = new MutationObserver(getExplicitColor);
elements.forEach((el) => {
ob.observe(el, {
attributes: true,
attributeFilter: ["class"]
});
});
const preferDarkQuery = window.matchMedia("(prefers-color-scheme: dark)");
const preferLightQuery = window.matchMedia("(prefers-color-scheme: light)");
preferDarkQuery.addEventListener("change", getSystemColor);
preferLightQuery.addEventListener("change", getSystemColor);
function getExplicitColor() {
let color;
for (const el of elements) {
if (el.classList.contains("dark")) {
color = "dark";
break;
}
if (el.classList.contains("light")) {
color = "light";
break;
}
}
explicitColor.value = color;
}
function getSystemColor() {
if (preferDarkQuery.matches)
systemColor.value = "dark";
else if (preferLightQuery.matches)
systemColor.value = "light";
else
systemColor.value = void 0;
}
getExplicitColor();
getSystemColor();
return computed(() => explicitColor.value || systemColor.value || "light");
}
function setupRouteTracking(timeline, router) {
if (timeline.options.enabled && router?.currentRoute?.value?.path) {
const start = timeline.events[0]?.start || Date.now();
timeline.events.unshift({
type: "route",
from: router.currentRoute.value.path,
to: router.currentRoute.value.path,
start,
end: start
});
}
let lastRouteEvent;
router?.afterEach(() => {
if (lastRouteEvent && !lastRouteEvent?.end)
lastRouteEvent.end = Date.now();
});
router?.beforeEach((to, from) => {
if (!timeline.options.enabled)
return;
lastRouteEvent = {
type: "route",
from: from.path,
to: to.path,
start: Date.now()
};
timeline.events.push(lastRouteEvent);
});
}
function setupReactivity(client, router, timeMetric) {
const refreshReactivity = debounce(() => {
client.hooks.callHook("host:update:reactivity");
}, 100, { trailing: true });
watch(() => [
client.nuxt.payload,
client.app.colorMode.value,
client.metrics.loading(),
timeMetric
], () => {
refreshReactivity();
}, { deep: true });
router?.afterEach(() => {
refreshReactivity();
});
client.nuxt.hook("app:mounted", () => {
refreshReactivity();
});
client.hooks.hook("devtools:navigate", (path) => {
state.value.route = path;
});
}