diff options
Diffstat (limited to 'packages/integrations/web-vitals/src')
-rw-r--r-- | packages/integrations/web-vitals/src/client-script.ts | 36 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/constants.ts | 1 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/db-config.ts | 22 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/endpoint.ts | 23 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/env.d.ts | 1 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/index.ts | 42 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/middleware.ts | 60 | ||||
-rw-r--r-- | packages/integrations/web-vitals/src/schemas.ts | 32 |
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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} 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>; |