summaryrefslogtreecommitdiff
path: root/packages/integrations/web-vitals/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/web-vitals/src')
-rw-r--r--packages/integrations/web-vitals/src/client-script.ts36
-rw-r--r--packages/integrations/web-vitals/src/constants.ts1
-rw-r--r--packages/integrations/web-vitals/src/db-config.ts22
-rw-r--r--packages/integrations/web-vitals/src/endpoint.ts23
-rw-r--r--packages/integrations/web-vitals/src/env.d.ts1
-rw-r--r--packages/integrations/web-vitals/src/index.ts42
-rw-r--r--packages/integrations/web-vitals/src/middleware.ts60
-rw-r--r--packages/integrations/web-vitals/src/schemas.ts32
8 files changed, 217 insertions, 0 deletions
diff --git a/packages/integrations/web-vitals/src/client-script.ts b/packages/integrations/web-vitals/src/client-script.ts
new file mode 100644
index 000000000..b69fa6772
--- /dev/null
+++ b/packages/integrations/web-vitals/src/client-script.ts
@@ -0,0 +1,36 @@
+import { type Metric, onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals';
+import { WEB_VITALS_ENDPOINT_PATH } from './constants.js';
+import type { ClientMetric } from './schemas.js';
+
+const pathname = location.pathname.replace(/(?<=.)\/$/, '');
+const route =
+ document
+ .querySelector<HTMLMetaElement>('meta[name="x-astro-vitals-route"]')
+ ?.getAttribute('content') || pathname;
+
+const queue = new Set<Metric>();
+const addToQueue = (metric: Metric) => queue.add(metric);
+function flushQueue() {
+ if (!queue.size) return;
+ const rawBody: ClientMetric[] = [...queue].map(({ name, id, value, rating }) => ({
+ pathname,
+ route,
+ name,
+ id,
+ value,
+ rating,
+ }));
+ const body = JSON.stringify(rawBody);
+ if (navigator.sendBeacon) navigator.sendBeacon(WEB_VITALS_ENDPOINT_PATH, body);
+ else fetch(WEB_VITALS_ENDPOINT_PATH, { body, method: 'POST', keepalive: true });
+ queue.clear();
+}
+
+for (const listener of [onCLS, onLCP, onINP, onFID, onFCP, onTTFB]) {
+ listener(addToQueue);
+}
+
+addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') flushQueue();
+});
+addEventListener('pagehide', flushQueue);
diff --git a/packages/integrations/web-vitals/src/constants.ts b/packages/integrations/web-vitals/src/constants.ts
new file mode 100644
index 000000000..7df5bb8b6
--- /dev/null
+++ b/packages/integrations/web-vitals/src/constants.ts
@@ -0,0 +1 @@
+export const WEB_VITALS_ENDPOINT_PATH = '/_web-vitals'
diff --git a/packages/integrations/web-vitals/src/db-config.ts b/packages/integrations/web-vitals/src/db-config.ts
new file mode 100644
index 000000000..918850f63
--- /dev/null
+++ b/packages/integrations/web-vitals/src/db-config.ts
@@ -0,0 +1,22 @@
+import { column, defineDb, defineTable } from 'astro:db';
+import { asDrizzleTable } from '@astrojs/db/utils';
+
+const Metric = defineTable({
+ columns: {
+ pathname: column.text(),
+ route: column.text(),
+ name: column.text(),
+ id: column.text({ primaryKey: true }),
+ value: column.number(),
+ rating: column.text(),
+ timestamp: column.date(),
+ },
+});
+
+export const AstrojsWebVitals_Metric = asDrizzleTable('AstrojsWebVitals_Metric', Metric);
+
+export default defineDb({
+ tables: {
+ AstrojsWebVitals_Metric: Metric,
+ },
+});
diff --git a/packages/integrations/web-vitals/src/endpoint.ts b/packages/integrations/web-vitals/src/endpoint.ts
new file mode 100644
index 000000000..10dea1ca8
--- /dev/null
+++ b/packages/integrations/web-vitals/src/endpoint.ts
@@ -0,0 +1,23 @@
+import { db, sql } from 'astro:db';
+import type { APIRoute } from 'astro';
+import { AstrojsWebVitals_Metric } from './db-config.js';
+import { ServerMetricSchema } from './schemas.js';
+
+export const prerender = false;
+
+export const ALL: APIRoute = async ({ request }) => {
+ try {
+ const rawBody = await request.json();
+ const body = ServerMetricSchema.array().parse(rawBody);
+ await db
+ .insert(AstrojsWebVitals_Metric)
+ .values(body)
+ .onConflictDoUpdate({
+ target: AstrojsWebVitals_Metric.id,
+ set: { value: sql`excluded.value` },
+ });
+ } catch (error) {
+ console.error(error);
+ }
+ return new Response();
+};
diff --git a/packages/integrations/web-vitals/src/env.d.ts b/packages/integrations/web-vitals/src/env.d.ts
new file mode 100644
index 000000000..18ef3554e
--- /dev/null
+++ b/packages/integrations/web-vitals/src/env.d.ts
@@ -0,0 +1 @@
+/// <reference types="@astrojs/db" />
diff --git a/packages/integrations/web-vitals/src/index.ts b/packages/integrations/web-vitals/src/index.ts
new file mode 100644
index 000000000..f8a5ad433
--- /dev/null
+++ b/packages/integrations/web-vitals/src/index.ts
@@ -0,0 +1,42 @@
+import { defineDbIntegration } from '@astrojs/db/utils';
+import { AstroError } from 'astro/errors';
+import { WEB_VITALS_ENDPOINT_PATH } from './constants.js';
+
+export default function webVitals() {
+ return defineDbIntegration({
+ name: '@astrojs/web-vitals',
+ hooks: {
+ 'astro:db:setup'({ extendDb }) {
+ extendDb({ configEntrypoint: '@astrojs/web-vitals/db-config' });
+ },
+
+ 'astro:config:setup'({ addMiddleware, config, injectRoute, injectScript }) {
+ if (!config.integrations.find(({ name }) => name === 'astro:db')) {
+ throw new AstroError(
+ 'Astro DB integration not found.',
+ 'Run `npx astro add db` to install `@astrojs/db` and add it to your Astro config.'
+ );
+ }
+
+ if (config.output !== 'hybrid' && config.output !== 'server') {
+ throw new AstroError(
+ 'No SSR adapter found.',
+ '`@astrojs/web-vitals` requires your site to be built with `hybrid` or `server` output.\n' +
+ 'Please add an SSR adapter: https://docs.astro.build/en/guides/server-side-rendering/'
+ );
+ }
+
+ // Middleware that adds a `<meta>` tag to each page.
+ addMiddleware({ entrypoint: '@astrojs/web-vitals/middleware', order: 'post' });
+ // Endpoint that collects metrics and inserts them in Astro DB.
+ injectRoute({
+ entrypoint: '@astrojs/web-vitals/endpoint',
+ pattern: WEB_VITALS_ENDPOINT_PATH,
+ prerender: false,
+ });
+ // Client-side performance measurement script.
+ injectScript('page', `import '@astrojs/web-vitals/client-script';`);
+ },
+ },
+ });
+}
diff --git a/packages/integrations/web-vitals/src/middleware.ts b/packages/integrations/web-vitals/src/middleware.ts
new file mode 100644
index 000000000..b4994c902
--- /dev/null
+++ b/packages/integrations/web-vitals/src/middleware.ts
@@ -0,0 +1,60 @@
+import type { MiddlewareHandler } from 'astro';
+
+/**
+ * Middleware which adds the web vitals `<meta>` tag to each page’s `<head>`.
+ *
+ * @example
+ * <meta name="x-astro-vitals-route" content="/blog/[slug]" />
+ */
+export const onRequest: MiddlewareHandler = async ({ params, url }, next) => {
+ const response = await next();
+ const contentType = response.headers.get('Content-Type');
+ if (contentType !== 'text/html') return response;
+ const webVitalsMetaTag = getMetaTag(url, params);
+ return new Response(
+ response.body
+ ?.pipeThrough(new TextDecoderStream())
+ .pipeThrough(HeadInjectionTransformStream(webVitalsMetaTag))
+ .pipeThrough(new TextEncoderStream()),
+ response
+ );
+};
+
+/** TransformStream which injects the passed HTML just before the closing </head> tag. */
+function HeadInjectionTransformStream(htmlToInject: string) {
+ let hasInjected = false;
+ return new TransformStream({
+ transform: (chunk, controller) => {
+ if (!hasInjected) {
+ const headCloseIndex = chunk.indexOf('</head>');
+ if (headCloseIndex > -1) {
+ chunk = chunk.slice(0, headCloseIndex) + htmlToInject + chunk.slice(headCloseIndex);
+ hasInjected = true;
+ }
+ }
+ controller.enqueue(chunk);
+ },
+ });
+}
+
+/** Get a `<meta>` tag to identify the current Astro route. */
+function getMetaTag(url: URL, params: Record<string, string | undefined>) {
+ let route = url.pathname;
+ for (const [key, value] of Object.entries(params)) {
+ if (value) route = route.replace(value, `[${key}]`);
+ }
+ route = miniEncodeAttribute(stripTrailingSlash(route));
+ return `<meta name="x-astro-vitals-route" content="${route}" />`;
+}
+
+function stripTrailingSlash(str: string) {
+ return str.length > 1 && str.at(-1) === '/' ? str.slice(0, -1) : str;
+}
+
+function miniEncodeAttribute(str: string) {
+ return str
+ .replaceAll('&', '&amp;')
+ .replaceAll('<', '&lt;')
+ .replaceAll('>', '&gt;')
+ .replaceAll('"', '&quot;');
+}
diff --git a/packages/integrations/web-vitals/src/schemas.ts b/packages/integrations/web-vitals/src/schemas.ts
new file mode 100644
index 000000000..7a2050bd5
--- /dev/null
+++ b/packages/integrations/web-vitals/src/schemas.ts
@@ -0,0 +1,32 @@
+import { z } from 'astro/zod';
+
+export const RatingSchema = z.enum(['good', 'needs-improvement', 'poor']);
+const MetricTypeSchema = z.enum(['CLS', 'INP', 'LCP', 'FCP', 'FID', 'TTFB']);
+
+/** `web-vitals` generated ID, transformed to reduce data resolution. */
+const MetricIdSchema = z
+ .string()
+ // Match https://github.com/GoogleChrome/web-vitals/blob/main/src/lib/generateUniqueID.ts
+ .regex(/^v3-\d{13}-\d{13}$/)
+ // Avoid collecting higher resolution timestamp in ID.
+ // Transforms `'v3-1711484350895-3748043125387'` to `'v3-17114843-3748043125387'`
+ .transform((id) => id.replace(/^(v3-\d{8})\d{5}(-\d{13})$/, '$1$2'));
+
+/** Shape of the data submitted from clients to the collection API. */
+const ClientMetricSchema = z.object({
+ pathname: z.string(),
+ route: z.string(),
+ name: MetricTypeSchema,
+ id: MetricIdSchema,
+ value: z.number().gte(0),
+ rating: RatingSchema,
+});
+
+/** Transformed client data with added timestamp. */
+export const ServerMetricSchema = ClientMetricSchema.transform((metric) => {
+ const timestamp = new Date();
+ timestamp.setMinutes(0, 0, 0);
+ return { ...metric, timestamp };
+});
+
+export type ClientMetric = z.input<typeof ClientMetricSchema>;