vuetify-nuxt-module
Version:
Zero-Config Nuxt Module for Vuetify
275 lines (274 loc) • 11.5 kB
JavaScript
import { setResponseHeader } from "h3";
import {
ssrClientHintsConfiguration
} from "virtual:vuetify-ssr-client-hints-configuration";
import { reactive } from "vue";
import { parseUserAgent } from "./detect-browser.js";
import { VuetifyHTTPClientHints } from "./client-hints.js";
import {
defineNuxtPlugin,
useCookie,
useNuxtApp,
useRequestEvent,
useRequestHeaders,
useState
} from "#imports";
const AcceptClientHintsHeaders = {
prefersColorScheme: "Sec-CH-Prefers-Color-Scheme",
prefersReducedMotion: "Sec-CH-Prefers-Reduced-Motion",
viewportHeight: "Sec-CH-Viewport-Height",
viewportWidth: "Sec-CH-Viewport-Width",
devicePixelRatio: "Sec-CH-DPR"
};
const AcceptClientHintsRequestHeaders = Object.entries(AcceptClientHintsHeaders).reduce((acc, [key, value]) => {
acc[key] = value.toLowerCase();
return acc;
}, {});
const SecChUaMobile = "Sec-CH-UA-Mobile".toLowerCase();
const HttpRequestHeaders = Array.from(Object.values(AcceptClientHintsRequestHeaders)).concat("user-agent", "cookie", SecChUaMobile);
const plugin = defineNuxtPlugin({
name: "vuetify:client-hints:server:plugin",
order: 24,
parallel: true,
setup(nuxtApp) {
const state = useState(VuetifyHTTPClientHints);
const requestHeaders = useRequestHeaders(HttpRequestHeaders);
const userAgentHeader = requestHeaders["user-agent"];
const userAgent = userAgentHeader ? parseUserAgent(userAgentHeader) : null;
const clientHintsRequest = collectClientHints(userAgent, ssrClientHintsConfiguration, requestHeaders);
writeClientHintsResponseHeaders(clientHintsRequest, ssrClientHintsConfiguration);
state.value = clientHintsRequest;
state.value.colorSchemeCookie = writeThemeCookie(
clientHintsRequest,
ssrClientHintsConfiguration
);
nuxtApp.hook("vuetify:before-create", async ({ vuetifyOptions }) => {
const clientWidth = clientHintsRequest.viewportWidth;
const clientHeight = clientHintsRequest.viewportHeight;
vuetifyOptions.ssr = typeof clientWidth === "number" ? {
clientWidth,
clientHeight
} : true;
if (clientHintsRequest.colorSchemeFromCookie) {
if (vuetifyOptions.theme === false) {
vuetifyOptions.theme = { defaultTheme: clientHintsRequest.colorSchemeFromCookie };
} else {
vuetifyOptions.theme ??= {};
vuetifyOptions.theme.defaultTheme = clientHintsRequest.colorSchemeFromCookie;
}
}
await nuxtApp.hooks.callHook("vuetify:ssr-client-hints", {
vuetifyOptions,
ssrClientHintsConfiguration: {
...ssrClientHintsConfiguration,
enabled: true
},
ssrClientHints: state.value
});
});
return {
provide: reactive({
ssrClientHints: state
})
};
}
});
const chromiumBasedBrowserFeatures = {
prefersColorScheme: (_, v) => v[0] >= 93,
prefersReducedMotion: (_, v) => v[0] >= 108,
viewportHeight: (_, v) => v[0] >= 108,
viewportWidth: (_, v) => v[0] >= 108,
devicePixelRatio: (_, v) => v[0] >= 46
};
const allowedBrowsers = [
// 'edge',
// 'edge-ios',
["chrome", chromiumBasedBrowserFeatures],
["edge-chromium", {
...chromiumBasedBrowserFeatures,
devicePixelRatio: (_, v) => v[0] >= 79
}],
["chromium-webview", chromiumBasedBrowserFeatures],
["opera", {
prefersColorScheme: (android, v) => v[0] >= (android ? 66 : 79),
prefersReducedMotion: (android, v) => v[0] >= (android ? 73 : 94),
viewportHeight: (android, v) => v[0] >= (android ? 73 : 94),
viewportWidth: (android, v) => v[0] >= (android ? 73 : 94),
devicePixelRatio: (_, v) => v[0] >= 33
}]
];
const ClientHeaders = ["Accept-CH", "Vary", "Critical-CH"];
function browserFeatureAvailable(userAgent, feature) {
if (userAgent == null || userAgent.type !== "browser")
return false;
try {
const browserName = userAgent.name;
const android = userAgent.os?.toLowerCase().startsWith("android") ?? false;
const versions = userAgent.version.split(".").map((v) => Number.parseInt(v));
return allowedBrowsers.some(([name, check]) => {
if (browserName !== name)
return false;
try {
return check[feature](android, versions);
} catch {
return false;
}
});
} catch {
return false;
}
}
function lookupClientHints(userAgent, ssrClientHintsConfiguration2, headers) {
const features = {
firstRequest: true,
prefersColorSchemeAvailable: false,
prefersReducedMotionAvailable: false,
viewportHeightAvailable: false,
viewportWidthAvailable: false,
devicePixelRatioAvailable: false
};
if (userAgent == null || userAgent.type !== "browser")
return features;
if (ssrClientHintsConfiguration2.prefersColorScheme)
features.prefersColorSchemeAvailable = browserFeatureAvailable(userAgent, "prefersColorScheme");
if (ssrClientHintsConfiguration2.prefersReducedMotion)
features.prefersReducedMotionAvailable = browserFeatureAvailable(userAgent, "prefersReducedMotion");
if (ssrClientHintsConfiguration2.viewportSize) {
features.viewportHeightAvailable = browserFeatureAvailable(userAgent, "viewportHeight");
features.viewportWidthAvailable = browserFeatureAvailable(userAgent, "viewportWidth");
const mobileHeader = headers[SecChUaMobile];
if (mobileHeader === "?1")
features.devicePixelRatioAvailable = browserFeatureAvailable(userAgent, "devicePixelRatio");
}
return features;
}
function collectClientHints(userAgent, ssrClientHintsConfiguration2, headers) {
const hints = lookupClientHints(userAgent, ssrClientHintsConfiguration2, headers);
if (ssrClientHintsConfiguration2.prefersColorScheme) {
if (ssrClientHintsConfiguration2.prefersColorSchemeOptions) {
const cookieName = ssrClientHintsConfiguration2.prefersColorSchemeOptions.cookieName;
const cookieValue = headers.cookie?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`));
if (cookieValue) {
const value = cookieValue.split("=")?.[1].trim();
if (ssrClientHintsConfiguration2.prefersColorSchemeOptions.themeNames.includes(value)) {
hints.colorSchemeFromCookie = value;
hints.firstRequest = false;
}
}
}
if (!hints.colorSchemeFromCookie) {
const value = hints.prefersColorSchemeAvailable ? headers[AcceptClientHintsRequestHeaders.prefersColorScheme]?.toLowerCase() : void 0;
if (value === "dark" || value === "light" || value === "no-preference") {
hints.prefersColorScheme = value;
hints.firstRequest = false;
}
if (ssrClientHintsConfiguration2.prefersColorSchemeOptions) {
if (!value || value === "no-preference") {
hints.colorSchemeFromCookie = ssrClientHintsConfiguration2.prefersColorSchemeOptions.defaultTheme;
} else {
hints.colorSchemeFromCookie = value === "dark" ? ssrClientHintsConfiguration2.prefersColorSchemeOptions.darkThemeName : ssrClientHintsConfiguration2.prefersColorSchemeOptions.lightThemeName;
}
}
}
}
if (hints.prefersReducedMotionAvailable && ssrClientHintsConfiguration2.prefersReducedMotion) {
const value = headers[AcceptClientHintsRequestHeaders.prefersReducedMotion]?.toLowerCase();
if (value === "no-preference" || value === "reduce") {
hints.prefersReducedMotion = value;
hints.firstRequest = false;
}
}
if (hints.viewportHeightAvailable && ssrClientHintsConfiguration2.viewportSize) {
const header = headers[AcceptClientHintsRequestHeaders.viewportHeight];
if (header) {
hints.firstRequest = false;
try {
hints.viewportHeight = Number.parseInt(header);
} catch {
hints.viewportHeight = ssrClientHintsConfiguration2.clientHeight;
}
}
} else {
hints.viewportHeight = ssrClientHintsConfiguration2.clientHeight;
}
if (hints.viewportWidthAvailable && ssrClientHintsConfiguration2.viewportSize) {
const header = headers[AcceptClientHintsRequestHeaders.viewportWidth];
if (header) {
hints.firstRequest = false;
try {
hints.viewportWidth = Number.parseInt(header);
} catch {
hints.viewportWidth = ssrClientHintsConfiguration2.clientWidth;
}
}
} else {
hints.viewportWidth = ssrClientHintsConfiguration2.clientWidth;
}
if (hints.devicePixelRatioAvailable && ssrClientHintsConfiguration2.viewportSize) {
const header = headers[AcceptClientHintsRequestHeaders.devicePixelRatio];
if (header) {
hints.firstRequest = false;
try {
hints.devicePixelRatio = Number.parseFloat(header);
if (!Number.isNaN(hints.devicePixelRatio) && hints.devicePixelRatio > 0) {
if (typeof hints.viewportWidth === "number")
hints.viewportWidth = Math.round(hints.viewportWidth / hints.devicePixelRatio);
if (typeof hints.viewportHeight === "number")
hints.viewportHeight = Math.round(hints.viewportHeight / hints.devicePixelRatio);
}
} catch {
}
}
}
return hints;
}
function writeClientHintHeaders(key, headers) {
ClientHeaders.forEach((header) => {
headers[header] = (headers[header] ? headers[header] : []).concat(key);
});
}
function writeClientHintsResponseHeaders(clientHintsRequest, ssrClientHintsConfiguration2) {
const headers = {};
if (ssrClientHintsConfiguration2.prefersColorScheme && clientHintsRequest.prefersColorSchemeAvailable)
writeClientHintHeaders(AcceptClientHintsHeaders.prefersColorScheme, headers);
if (ssrClientHintsConfiguration2.prefersReducedMotion && clientHintsRequest.prefersReducedMotionAvailable)
writeClientHintHeaders(AcceptClientHintsHeaders.prefersReducedMotion, headers);
if (ssrClientHintsConfiguration2.viewportSize && clientHintsRequest.viewportHeightAvailable && clientHintsRequest.viewportWidthAvailable) {
writeClientHintHeaders(AcceptClientHintsHeaders.viewportHeight, headers);
writeClientHintHeaders(AcceptClientHintsHeaders.viewportWidth, headers);
if (clientHintsRequest.devicePixelRatioAvailable)
writeClientHintHeaders(AcceptClientHintsHeaders.devicePixelRatio, headers);
}
if (Object.keys(headers).length === 0)
return;
const nuxtApp = useNuxtApp();
const callback = () => {
const event = useRequestEvent(nuxtApp);
Object.entries(headers).forEach(([key, value]) => {
setResponseHeader(event, key, value);
});
};
const unhook = nuxtApp.hooks.hookOnce("app:rendered", callback);
nuxtApp.hooks.hookOnce("app:error", () => {
unhook();
return callback();
});
}
function writeThemeCookie(clientHintsRequest, ssrClientHintsConfiguration2) {
if (!ssrClientHintsConfiguration2.prefersColorScheme || !ssrClientHintsConfiguration2.prefersColorSchemeOptions)
return;
const cookieName = ssrClientHintsConfiguration2.prefersColorSchemeOptions.cookieName;
const themeName = clientHintsRequest.colorSchemeFromCookie ?? ssrClientHintsConfiguration2.prefersColorSchemeOptions.defaultTheme;
const path = ssrClientHintsConfiguration2.prefersColorSchemeOptions.baseUrl;
const date = /* @__PURE__ */ new Date();
const expires = new Date(date.setDate(date.getDate() + 365));
if (!clientHintsRequest.firstRequest || !ssrClientHintsConfiguration2.reloadOnFirstRequest) {
useCookie(cookieName, {
path,
expires,
sameSite: "lax"
}).value = themeName;
}
return `${cookieName}=${themeName}; Path=${path}; Expires=${expires.toUTCString()}; SameSite=Lax`;
}
export default plugin;