From 393d4056cd47265cb1103c3d15806f2ac093e7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 28 Mar 2026 12:54:12 +0100 Subject: [PATCH] chore: inline ga4 module and remove raw GitHub import Replace the `$ga4` raw GitHub URL import with a local inlined module. The ga4 package was never published to jsr or npm, so inlining is the cleanest way to remove the URL-based dependency. - Inlined ga4 source into www/utils/ga4.ts - Replaced old deno.land/std URL imports with @std/http - Inlined the trivial snakeCase helper (was from deno.land/x/case) - Removed $ga4 import map entry from deno.json Co-Authored-By: Claude Opus 4.6 (1M context) --- deno.json | 1 - deno.lock | 31 --- www/routes/_middleware.ts | 4 +- www/utils/ga4.ts | 412 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 414 insertions(+), 34 deletions(-) create mode 100644 www/utils/ga4.ts diff --git a/deno.json b/deno.json index 93068a35fc4..ef74c4d7bae 100644 --- a/deno.json +++ b/deno.json @@ -61,7 +61,6 @@ "mime-db": "npm:mime-db@^1.54.0", "preact": "npm:preact@^10.28.2", "preact-render-to-string": "npm:preact-render-to-string@^6.6.5", - "$ga4": "https://raw.githubusercontent.com/denoland/ga4/main/mod.ts", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "@preact/signals": "npm:@preact/signals@^2.5.1", "@std/encoding": "jsr:@std/encoding@1", diff --git a/deno.lock b/deno.lock index 268b1982511..3554f9a0362 100644 --- a/deno.lock +++ b/deno.lock @@ -4145,37 +4145,6 @@ ] } }, - "redirects": { - "https://esm.sh/@types/react@~19.0.7/index.d.ts": "https://esm.sh/@types/react@19.0.14/index.d.ts", - "https://github.com/denoland/std/raw/refs/heads/main/_tools/check_docs.ts": "https://raw.githubusercontent.com/denoland/std/refs/heads/main/_tools/check_docs.ts" - }, - "remote": { - "https://deno.land/std@0.120.0/async/deadline.ts": "1d6ac7aeaee22f75eb86e4e105d6161118aad7b41ae2dd14f4cfd3bf97472b93", - "https://deno.land/std@0.120.0/async/debounce.ts": "b2f693e4baa16b62793fd618de6c003b63228db50ecfe3bd51fc5f6dc0bc264b", - "https://deno.land/std@0.120.0/async/deferred.ts": "ab60d46ba561abb3b13c0c8085d05797a384b9f182935f051dc67136817acdee", - "https://deno.land/std@0.120.0/async/delay.ts": "f2d8ccaa8ebc26594bd8b0989edfd8a96257a714c1dee2fb54d986e5bdd840ac", - "https://deno.land/std@0.120.0/async/mod.ts": "78425176fabea7bd1046ce3819fd69ce40da85c83e0f174d17e8e224a91f7d10", - "https://deno.land/std@0.120.0/async/mux_async_iterator.ts": "62abff3af9ff619e8f2adc96fc70d4ca020fa48a50c23c13f12d02ed2b760dbe", - "https://deno.land/std@0.120.0/async/pool.ts": "353ce4f91865da203a097aa6f33de8966340c91b6f4a055611c8c5d534afd12f", - "https://deno.land/std@0.120.0/async/tee.ts": "3e9f2ef6b36e55188de16a667c702ace4ad0cf84e3720379160e062bf27348ad", - "https://deno.land/std@0.120.0/http/http_status.ts": "2ff185827bff21c7be2807fcb09a6a2166464ba57fcd94afe805abab8e09070a", - "https://deno.land/std@0.120.0/http/server.ts": "d0be8a9da160255623e645f5b515fa1c6b65eecfbb9cad87ef8002d4f8d56616", - "https://deno.land/std@0.143.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", - "https://deno.land/std@0.143.0/datetime/formatter.ts": "7c8e6d16a0950f400aef41b9f1eb9168249869776ec520265dfda785d746589e", - "https://deno.land/std@0.143.0/datetime/mod.ts": "dcab9ae7be83cbf74b7863e83bd16e7c646a8dea2f019092905630eb7a545739", - "https://deno.land/std@0.143.0/datetime/tokenizer.ts": "7381e28f6ab51cb504c7e132be31773d73ef2f3e1e50a812736962b9df1e8c47", - "https://deno.land/std@0.143.0/http/cookie.ts": "526f27762fad7bf84fbe491de7eba7c406057501eec6edcad7884a16b242fddf", - "https://deno.land/x/case@2.1.1/lowerCase.ts": "86d5533f9587ed60003181591e40e648838c23f371edfa79d00288153d113b16", - "https://deno.land/x/case@2.1.1/normalCase.ts": "6a8b924da9ab0790d99233ae54bfcfc996d229cb91b2533639fe20972cc33dac", - "https://deno.land/x/case@2.1.1/snakeCase.ts": "ee2ab4e2c931d30bb79190d090c21eb5c00d1de1b7a9a3e7f33e035ae431333b", - "https://deno.land/x/case@2.1.1/types.ts": "8e2bd6edaa27c0d1972c0d5b76698564740f37b4d3787d58d1fb5f48de611e61", - "https://deno.land/x/case@2.1.1/vendor/camelCaseRegexp.ts": "7d9ff02aad4ab6429eeab7c7353f7bcdd6cc5909a8bd3dda97918c8bbb7621ae", - "https://deno.land/x/case@2.1.1/vendor/camelCaseUpperRegexp.ts": "292de54a698370f90adcdf95727993d09888b7f33d17f72f8e54ba75f7791787", - "https://deno.land/x/case@2.1.1/vendor/nonWordRegexp.ts": "c1a052629a694144b48c66b0175a22a83f4d61cb40f4e45293fc5d6b123f927e", - "https://raw.githubusercontent.com/denoland/ga4/main/mod.ts": "36f72ba1c90b5ebdb811427f367cd95fa6772d2de2fb45d6e57550501ee6d476", - "https://raw.githubusercontent.com/denoland/std/refs/heads/main/_tools/check_docs.ts": "7b87b9503a45f9a197382fbc308637d16906918653628a9ab4bc7388938c851b", - "https://raw.githubusercontent.com/denoland/std/refs/heads/main/_tools/utils.ts": "22441d7c8460b2f23ac48b0362178a1d60f9d06ead2496bd397363e6a1ce9105" - }, "workspace": { "dependencies": [ "jsr:@astral/astral@~0.5.5", diff --git a/www/routes/_middleware.ts b/www/routes/_middleware.ts index ca4a0e7a5ad..bae6c57d832 100644 --- a/www/routes/_middleware.ts +++ b/www/routes/_middleware.ts @@ -1,6 +1,6 @@ import type { Context } from "fresh"; -import type { Event } from "$ga4"; -import { GA4Report, isDocument, isServerError } from "$ga4"; +import type { Event } from "../utils/ga4.ts"; +import { GA4Report, isDocument, isServerError } from "../utils/ga4.ts"; const GA4_MEASUREMENT_ID = Deno.env.get("GA4_MEASUREMENT_ID"); diff --git a/www/utils/ga4.ts b/www/utils/ga4.ts new file mode 100644 index 00000000000..3a81106cbab --- /dev/null +++ b/www/utils/ga4.ts @@ -0,0 +1,412 @@ +// Copyright 2021-2022 the Deno authors. All rights reserved. MIT license. +// Inlined from https://github.com/denoland/ga4 + +import { getCookies } from "@std/http/cookie"; +import { STATUS_TEXT } from "@std/http/status"; + +const GA4_ENDPOINT_URL = "https://www.google-analytics.com/g/collect"; +const SLOW_UPLOAD_THRESHOLD = 1_000; + +export type Primitive = bigint | boolean | null | number | string; + +export type Client = { + id?: string; // Must have either `ip` or `id`. + ip?: string; + language?: string; + headers: Headers; +}; + +export interface User { + id?: string; + properties: Record; +} + +export interface Session { + id: string; + number: number; + engaged: boolean; + start?: boolean; + hitCount: number; +} + +export interface Page { + location: string; + title: string; + referrer?: string; + ignoreReferrer?: boolean; + trafficType?: string; + firstVisit?: boolean; + newToSite?: boolean; +} + +export interface Campaign { + source?: string; + medium?: string; + id?: string; + name?: string; + content?: string; + term?: string; +} + +export interface Event { + name: string; + category?: string; + label?: string; + params: Record; +} + +// Defaults to "page_view", but can be overridden/surpressed. +export type PrimaryEvent = Event | null; + +export interface GA4Init { + measurementId?: string; + request: Request; + response: Response; + conn: { remoteAddr?: { hostname: string } }; +} + +export class GA4Report { + measurementId?: string; + client: Client; + user: User; + session?: Session; + campaign?: Campaign; + page: Page; + events: [PrimaryEvent, ...Event[]]; + + constructor({ measurementId, request, response, conn }: GA4Init) { + this.measurementId = measurementId; + this.client = { + id: getClientId(request), + ip: getClientIp(request, conn), + language: getClientLanguage(request), + headers: getClientHeaders(request), + }; + this.user = { properties: {} }; + this.page = { + location: request.url, + title: getPageTitle(request, response), + referrer: getPageReferrer(request), + firstVisit: getFirstVisit(request), + }; + this.campaign = getCampaignObject(request); + this.events = [{ name: "page_view", params: {} }]; + } + + get event(): PrimaryEvent { + return this.events[0]; + } + + set event(event: PrimaryEvent) { + this.events[0] = event; + } + + async send(): Promise { + // Short circuit if there are no events to report. + if (!this.events.find(Boolean)) { + return; + } + + this.measurementId ??= Deno.env.get("GA4_MEASUREMENT_ID"); + if (!this.measurementId) { + return this.warn( + "GA4_MEASUREMENT_ID environment variable not set. " + + "Google Analytics reporting disabled.", + ); + } + + if (this.client.id == null) { + if (this.client.ip == null) { + return this.warn("either `client.id` or `client.ip` must be set."); + } + const material = [ + this.client.ip, + this.client.headers.get("user-agent"), + this.client.headers.get("sec-ch-ua"), + ].join(); + this.client.id = await toDigest(material); + } + + const queryParams: Record = {}; + + addShortParam(queryParams, "v", 2); + addShortParam(queryParams, "tid", this.measurementId); + + addShortParam(queryParams, "cid", this.client.id); + addShortParam(queryParams, "ul", this.client.language); + addShortParam(queryParams, "_uip", this.client.ip); + + addShortParam(queryParams, "uid", this.user.id); + for (const [name, value] of Object.entries(this.user.properties)) { + addCustomParam(queryParams, "up", name, value); + } + + addShortParam(queryParams, "cs", this.campaign?.source); + addShortParam(queryParams, "cm", this.campaign?.medium); + addShortParam(queryParams, "ci", this.campaign?.id); + addShortParam(queryParams, "cn", this.campaign?.name); + addShortParam(queryParams, "cc", this.campaign?.content); + addShortParam(queryParams, "ck", this.campaign?.term); + + addShortParam(queryParams, "sid", this.session?.id); + addShortParam(queryParams, "sct", this.session?.number); + addShortParam(queryParams, "seg", this.session?.engaged); + addShortParam(queryParams, "_s", this.session?.hitCount); + + addShortParam(queryParams, "dl", this.page.location); + addShortParam(queryParams, "dr", this.page.referrer); + addShortParam(queryParams, "dt", this.page.title); + addShortParam(queryParams, "ir", this.page.ignoreReferrer, false); + + if (this.event != null) { + addEventParams(queryParams, this.event); + addShortParam(queryParams, "en", this.event.name); + addShortParam(queryParams, "_fv", this.page.firstVisit, false); + addShortParam(queryParams, "_nts", this.page.newToSite, false); + addShortParam(queryParams, "_ss", this.session?.start, false); + } + + const extraEvents = this.events.slice(1) as Event[]; + const eventParamsList = extraEvents.map((event) => { + const eventParams: Record = {}; + addShortParam(eventParams, "en", event.name); + addEventParams(eventParams, event); + return eventParams; + }); + + const url = Object.assign(new URL(GA4_ENDPOINT_URL), { + search: String(new URLSearchParams(queryParams)), + }).href; + + const headers = this.client.headers; + + const body = eventParamsList.map((eventParams) => + new URLSearchParams(eventParams).toString() + ).join("\n"); + + const request = new Request(url, { method: "POST", headers, body }); + + try { + const start = performance.now(); + const response = await fetch(request); + const duration = performance.now() - start; + + if (this.session && response.ok) { + if (this.event != null) { + this.session.start = undefined; + } + const hitCount = this.events.filter(Boolean).length || 1; + this.session.hitCount += hitCount; + } + + if (response.status !== 204 || duration >= SLOW_UPLOAD_THRESHOLD) { + this.warn( + `${this.events.length} events uploaded in ${duration}ms. ` + + `Response: ${response.status} ${response.statusText}`, + ); + } + } catch (err) { + this.warn(`Upload failed: ${err}`); + } + } + + warn(message: unknown) { + // deno-lint-ignore no-console + console.warn(`GA4: ${message}`); + } +} + +function getClientId(request: Request): string | undefined { + const cookies = getCookies(request.headers); + return cookies._ga ? cookies._ga : undefined; +} + +function getClientIp( + request: Request, + conn: { remoteAddr?: { hostname: string } }, +): string { + const xForwardedFor = request.headers.get("x-forwarded-for"); + if (xForwardedFor) { + return xForwardedFor.split(/\s*,\s*/)[0]; + } else { + return conn.remoteAddr?.hostname ?? "0.0.0.0"; + } +} + +function getClientLanguage(request: Request): string | undefined { + const acceptLanguage = request.headers.get("accept-language"); + if (acceptLanguage == null) { + return; + } + const code = acceptLanguage.split(/[^a-z-]+/i).filter(Boolean).shift(); + if (code == null) { + return undefined; + } + return code.toLowerCase(); +} + +function getClientHeaders(request: Request): Headers { + const headerList = [ + ...(request.headers as unknown as Iterable<[string, string]>), + ].filter(([name, _value]) => { + name = name.toLowerCase(); + return name === "user-agent" || name === "sec-ch-ua" || + name.startsWith("sec-ch-ua-"); + }); + return new Headers(headerList); +} + +function getPageTitle(request: Request, response: Response): string { + if ( + (request.method === "GET" || request.method === "HEAD") && + isSuccess(response) + ) { + return new URL(request.url) + .pathname + .replace(/\.[^\/]*$/, "") // Remove file extension. + .split(/\/+/) // Split into components. + .map(decodeURIComponent) // Unescape. + .map((s) => s.replace(/[\s_]+/g, " ")) // Underbars to spaces. + .map((s) => s.replace(/@v?[\d\.\s]+$/, "")) // Remove version number. + .map((s) => s.trim()) // Trim leading/trailing whitespace. + .filter(Boolean) // Remove empty path components. + .join(" / ") || + "/"; + } else { + return formatStatus(response).toLowerCase(); + } +} + +function getPageReferrer(request: Request): string | undefined { + const referrer = request.headers.get("referer"); + if ( + referrer !== null && new URL(referrer).host !== new URL(request.url).host + ) { + return referrer; + } +} + +function getFirstVisit(request: Request): boolean | undefined { + return getClientId(request) ? false : true; +} + +function getCampaignObject(request: Request): Campaign { + const url = new URL(request.url); + return { + name: url.searchParams.get("utm_campaign") || undefined, + source: url.searchParams.get("utm_source") || undefined, + medium: url.searchParams.get("utm_medium") || undefined, + content: url.searchParams.get("utm_content") || undefined, + term: url.searchParams.get("utm_term") || undefined, + }; +} + +export function formatStatus(response: Response): string { + let { status, statusText } = response; + statusText ||= STATUS_TEXT[status as keyof typeof STATUS_TEXT] ?? + "Invalid Status"; + return `${status} ${statusText}`; +} + +export function isSuccess(response: Response): boolean { + const { status } = response; + return status >= 200 && status <= 299; +} + +export function isRedirect(response: Response): boolean { + const { status } = response; + return status >= 300 && status <= 399; +} + +export function isServerError(response: Response): boolean { + const { status } = response; + return status >= 500 && status <= 599; +} + +function addShortParam( + params: Record, + name: string, + value?: Primitive, + implicitDefault?: Primitive, +) { + if (value === undefined || value === implicitDefault) { + // Do nothing. + } else if (typeof value === "boolean") { + params[name] = value ? "1" : "0"; + } else { + params[name] = String(value); + } +} + +function snakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[\s-]+/g, "_") + .toLowerCase(); +} + +function addCustomParam( + params: Record, + prefix: string, + name: string, + value?: Primitive, +) { + if (value === undefined) { + return; + } + name = snakeCase(name); + if (typeof value === "number" || typeof value === "bigint") { + params[`${prefix}n.${name}`] = String(value); + } else { + params[`${prefix}.${name}`] = String(value); + } +} + +function addEventParams(params: Record, event: Event) { + for (const prop of ["category", "label"] as const) { + addCustomParam(params, "ep", `event_${prop}`, event[prop]); + } + for (const [name, value] of Object.entries(event.params)) { + addCustomParam(params, "ep", name, value); + } +} + +const encoder = new TextEncoder(); + +async function toDigest(msg: string): Promise { + const buffer = await crypto.subtle.digest("SHA-1", encoder.encode(msg)); + return Array.from(new Uint8Array(buffer)).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); +} + +export function isDocument(request: Request, response: Response): boolean { + const fetchMode = request.headers.get("sec-fetch-mode"); + if (fetchMode != null) { + return fetchMode === "navigate"; + } + + const disposition = response.headers.get("content-disposition"); + if (disposition != null && /^attachment\b/i.test(disposition)) { + return true; + } + + const accept = request.headers.get("accept"); + if (accept != null && /^text\/html\b/i.test(accept)) { + return true; + } + + const { method } = request; + const referer = request.headers.get("referer"); + const userAgent = request.headers.get("user-agent"); + if ( + method === "GET" && + referer == null && + (userAgent == null || !userAgent.startsWith("Mozilla/")) && + (accept == null || accept === "*/*") + ) { + return true; + } + + return false; +}