From 9bf513fb045937644eed5ff29564cf516a75cecd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 29 Oct 2022 13:56:29 -0700 Subject: [PATCH] Move (#148) --- .eleventyignore | 5 +- README.md | 6 +- _includes/layouts/base.njk | 2 +- api/ga.js | 150 +++++++++++++++++++++++++++++++++++++ feed/feed.njk | 2 +- functions/ga.js | 140 ---------------------------------- js/cached.js | 2 +- netlify.toml | 14 ---- test/test-generic-post.js | 2 +- vercel.json | 78 +++++++++++++++++++ 10 files changed, 238 insertions(+), 163 deletions(-) create mode 100644 api/ga.js delete mode 100644 functions/ga.js delete mode 100644 netlify.toml create mode 100644 vercel.json diff --git a/.eleventyignore b/.eleventyignore index 5183f62..021f952 100644 --- a/.eleventyignore +++ b/.eleventyignore @@ -1,11 +1,12 @@ .DS_Store .github/ +.vercel/ .netlify/ -_site/ +\_site/ node_modules/ package-lock.json README.md -_11ty/ +\_11ty/ third_party functions test diff --git a/README.md b/README.md index c11c133..28d9bfc 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ npm run build #### Miscellaneous - Immutable URLs for JS. -- Sets immutable caching headers for images, fonts, and JS (CSS is inlined). -- Minifies HTML and optimizes it for compression. Uses [html-minifier](https://www.npmjs.com/package/html-minifier) with aggressive options. +- Sets immutable caching headers for images, fonts, and JS (CSS is inlined). Automatically configured when deploying on [Vercel](https://vercel.com/) +- Uses [html-minifier](https://www.npmjs.com/package/html-minifier) with aggressive options. - Uses [rollup](https://rollupjs.org/) to bundle JS and minifies it with [terser](https://terser.org/). - Prefetches same-origin navigations when a navigation is likely. - If an AMP files is present, [optimizes it](https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/). @@ -109,7 +109,7 @@ npm run build #### Analytics -- Supports locally serving Google Analytics's JS and proxying it's hit requests to a serverless function proxy (other proxies could be easily added). +- Supports locally serving Google Analytics's JS and proxying it's hit requests to a Vercel Edge Function (other proxies could be easily added). - Supports sending [Core Web Vitals](https://web.dev/vitals/) metrics to Google Analytics as [events](https://github.com/GoogleChrome/web-vitals#send-the-results-to-google-analytics). - Support for noscript hit requests. - Avoids blocking onload on analytics requests. diff --git a/_includes/layouts/base.njk b/_includes/layouts/base.njk index b64e6e3..4addcc9 100644 --- a/_includes/layouts/base.njk +++ b/_includes/layouts/base.njk @@ -72,7 +72,7 @@ {% if googleanalytics %} {% endif %} diff --git a/api/ga.js b/api/ga.js new file mode 100644 index 0000000..b44aafe --- /dev/null +++ b/api/ga.js @@ -0,0 +1,150 @@ +const GA_ENDPOINT = `https://www.google-analytics.com/collect`; + +// Domains to allowlist. Replace with your own! +const originallowlist = []; +// Update me. +allowlistDomain("eleventy-high-performance-blog-sample.industrialempathy.com/"); + +let hot = false; +let age = Date.now(); + +export const config = { + runtime: "experimental-edge", +}; + +export default async function (req, event) { + const url = new URL(req.url); + if (req.method === "GET" && !url.search) { + return new Response("OK", { status: 200 }); + } + + const origin = req.headers.get("origin") || ""; + console.log(`Received ${req.method} request from, origin: ${origin}`); + + const isOriginallowlisted = + originallowlist.indexOf(origin) >= 0 || + origin.endsWith("-cramforce.vercel.app") || + origin.endsWith("-team-malte.vercel.app"); + if (!isOriginallowlisted) { + console.info("Bad origin", origin); + return new Response("Not found", { status: 404 }); + } + + let cacheControl = "no-store"; + if (url.searchParams.get("ec") == "noscript") { + cacheControl = "max-age: 30"; + } + const headers = { + "Access-Control-Allow-Origin": isOriginallowlisted + ? origin + : originallowlist[0], + "Cache-Control": cacheControl, + "x-age": `${hot}; ${Date.now() - age}`, + }; + hot = true; + + event.waitUntil(proxyToGoogleAnalytics(req, url, await req.text())); + return new Response("D", { status: 200, headers }); +} + +function allowlistDomain(domain, addWww = true) { + const prefixes = ["https://", "http://"]; + if (addWww) { + prefixes.push("https://www."); + prefixes.push("http://www."); + } + prefixes.forEach((prefix) => originallowlist.push(prefix + domain)); +} + +async function cid(ip, otherStuff) { + if (ip) { + const encoder = new TextEncoder(); + const data = encoder.encode( + "sha256", + ip + otherStuff + "this is open source" + ); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return hashHex; + } + return Math.random() * 1000; // They use a decimal looking format. It really doesn't matter. +} + +async function proxyToGoogleAnalytics(req, url, body) { + // get GA params whether GET or POST request + const params = + req.method.toUpperCase() === "GET" + ? url.searchParams + : new URLSearchParams(body); + const headers = req.headers; + + // attach other GA params, required for IP address since client doesn't have access to it. UA and CID can be sent from client + params.set( + "uip", + headers.get("x-forwarded-for") || headers.get("x-bb-ip") || "" + ); // ip override. Look into headers for clients IP address, as opposed to IP address of host running lambda function + params.set("ua", params.get("ua") || headers.get("user-agent") || ""); // user agent override + params.set( + "cid", + params.get("cid") || (await cid(params.get("uip", params.get("ua")))) + ); + + const qs = params.toString(); + console.info("proxying params:", qs); + + const reqOptions = { + method: "POST", + headers: { + "Content-Type": "image/gif", + }, + body: qs, + }; + let result; + try { + result = await fetch(GA_ENDPOINT, reqOptions); + } catch (e) { + console.error("googleanalytics error!", e); + return; + } + if (result.status == 200) { + console.debug("googleanalytics request successful"); + return; + } + console.error( + "googleanalytics status code", + result.status, + result.statusText + ); +} + +/* + Docs on GA endpoint and example params + + https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide + +v: 1 +_v: j67 +a: 751874410 +t: pageview +_s: 1 +dl: https://nfeld.com/contact.html +dr: https://google.com +ul: en-us +de: UTF-8 +dt: Nikolay Feldman - Software Engineer +sd: 24-bit +sr: 1440x900 +vp: 945x777 +je: 0 +_u: blabla~ +jid: +gjid: +cid: 1837873423.1522911810 +tid: UA-116530991-1 +_gid: 1828045325.1524815793 +gtm: u4d +z: 1379041260 +*/ diff --git a/feed/feed.njk b/feed/feed.njk index 93bc98c..74bf281 100755 --- a/feed/feed.njk +++ b/feed/feed.njk @@ -27,7 +27,7 @@ eleventyExcludeFromCollections: true {% if googleanalytics %} {% set titleUrlEncoded = post.data.title|encodeURIComponent %} {% set urlUrlEncoded = post.url | encodeURIComponent %} - {{''}} + {{''}} {% endif %} diff --git a/functions/ga.js b/functions/ga.js deleted file mode 100644 index 3891431..0000000 --- a/functions/ga.js +++ /dev/null @@ -1,140 +0,0 @@ -const request = require("phin"); -const querystring = require("querystring"); - -const GA_ENDPOINT = `https://www.google-analytics.com/collect`; - -// Domains to allowlist. Replace with your own! -const originallowlist = []; // keep this empty and append domains to allowlist using allowlistDomain() -// Update me. -allowlistDomain("eleventy-high-performance-blog-sample.industrialempathy.com/"); - -function allowlistDomain(domain, addWww = true) { - const prefixes = ["https://", "http://"]; - if (addWww) { - prefixes.push("https://www."); - prefixes.push("http://www."); - } - prefixes.forEach((prefix) => originallowlist.push(prefix + domain)); -} - -function cid(ip, otherStuff) { - if (ip) { - return require("crypto") - .createHmac("sha256", ip + otherStuff + new Date().toLocaleDateString()) - .update("this is open source") - .digest("hex"); - } - return Math.random() * 1000; // They use a decimal looking format. It really doesn't matter. -} - -function proxyToGoogleAnalytics(event) { - // get GA params whether GET or POST request - const params = - event.httpMethod.toUpperCase() === "GET" - ? event.queryStringParameters - : querystring.parse(event.body); - const headers = event.headers || {}; - - // attach other GA params, required for IP address since client doesn't have access to it. UA and CID can be sent from client - params.uip = headers["x-forwarded-for"] || headers["x-bb-ip"] || ""; // ip override. Look into headers for clients IP address, as opposed to IP address of host running lambda function - params.ua = params.ua || headers["user-agent"] || ""; // user agent override - params.cid = params.cid || cid(params.uip, params.ua); - - const qs = querystring.stringify(params); - console.info("proxying params:", Object.keys(params).join(", ")); - - const reqOptions = { - method: "POST", - headers: { - "Content-Type": "image/gif", - }, - url: GA_ENDPOINT, - data: qs, - }; - - request(reqOptions, (error, result) => { - if (error) { - console.info("googleanalytics error!", error); - } else { - if (result.statusCode == 200) { - return; - } - console.info( - "googleanalytics status code", - result.statusCode, - result.statusMessage - ); - } - }); -} - -exports.handler = function (event, context, callback) { - const origin = event.headers["origin"] || event.headers["Origin"] || ""; - console.log(`Received ${event.httpMethod} request from, origin: ${origin}`); - - const isOriginallowlisted = originallowlist.indexOf(origin) >= 0; - if (!isOriginallowlisted) { - console.info("Bad origin", origin); - } - - let cacheControl = "no-store"; - if (event.queryStringParameters["ec"] == "noscript") { - cacheControl = "max-age: 30"; - } - - const headers = { - //'Access-Control-Allow-Origin': '*', // allow all domains to POST. Use for localhost development only - "Access-Control-Allow-Origin": isOriginallowlisted - ? origin - : originallowlist[0], - "Cache-Control": cacheControl, - }; - - const done = () => { - callback(null, { - statusCode: 204, - headers, - body: "", - }); - }; - - if (event.httpMethod === "OPTIONS") { - // CORS (required if you use a different subdomain to host this function, or a different domain entirely) - done(); - } else if (isOriginallowlisted) { - // allow GET or POST, but only for allowlisted domains - done(); // Fire and forget - proxyToGoogleAnalytics(event); - } else { - callback("Not found"); - } -}; - -/* - Docs on GA endpoint and example params - - https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide - -v: 1 -_v: j67 -a: 751874410 -t: pageview -_s: 1 -dl: https://nfeld.com/contact.html -dr: https://google.com -ul: en-us -de: UTF-8 -dt: Nikolay Feldman - Software Engineer -sd: 24-bit -sr: 1440x900 -vp: 945x777 -je: 0 -_u: blabla~ -jid: -gjid: -cid: 1837873423.1522911810 -tid: UA-116530991-1 -_gid: 1828045325.1524815793 -gtm: u4d -z: 1379041260 -*/ diff --git a/js/cached.js b/js/cached.js index c34cfc6..3c6407b 100644 --- a/js/cached.js +++ b/js/cached.js @@ -37,7 +37,7 @@ d){var e=O.XMLHttpRequest;if(!e)return!1;var g=new e;if(!("withCredentials"in g) l)pe("https://stats.g.doubleclick.net/j/collect",d.U,d,c);else if("g"==l){wc("https://www.google.%/ads/ga-audiences".replace("%","com"),d.google,c);var k=ca.substring(2);k&&(/^[a-z.]{1,6}$/.test(k)?wc("https://www.google.%/ads/ga-audiences".replace("%",k),d.google,ua):ge("tld","bcc",k))}else ge("xhr","brc",l),c()}}catch(w){ge("xhr","rsp"),c()}else c();g=null}};g.send(b);return!0},x=function(a,b,c){return O.navigator.sendBeacon?O.navigator.sendBeacon(a,b)?(c(),!0):!1:!1},ge=function(a,b,c){1<=100* Math.random()||G("?")||(a=["t=error","_e="+a,"_v=j83","sr=1"],b&&a.push("_f="+b),c&&a.push("_m="+K(c.substring(0,100))),a.push("aip=1"),a.push("z="+hd()),wc(bd(!0)+"/u/d",a.join("&"),ua))};var qc=function(){return O.gaData=O.gaData||{}},h=function(a){var b=qc();return b[a]=b[a]||{}};var Ha=function(){this.M=[]};Ha.prototype.add=function(a){this.M.push(a)};Ha.prototype.D=function(a){try{for(var b=0;b=100*R(a,Ka))throw"abort";}function Ma(a){if(G(P(a,Na)))throw"abort";}function Oa(){var a=M.location.protocol;if("http:"!=a&&"https:"!=a)throw"abort";} function Pa(a){try{O.navigator.sendBeacon?J(42):O.XMLHttpRequest&&"withCredentials"in new O.XMLHttpRequest&&J(40)}catch(c){}a.set(ld,Td(a),!0);a.set(Ac,R(a,Ac)+1);var b=[];ue.map(function(c,d){d.F&&(c=a.get(c),void 0!=c&&c!=d.defaultValue&&("boolean"==typeof c&&(c*=1),b.push(d.F+"="+K(""+c))))});!1===a.get(xe)&&b.push("npa=1");b.push("z="+Bd());a.set(Ra,b.join("&"),!0)} -function Sa(a){var b=P(a,fa);!b&&a.get(Vd)&&(b="beacon");var c=P(a,gd),d=P(a,oe),e=c||(d||bd(!1)+"")+"/.netlify/functions/ga";switch(P(a,ad)){case "d":e=c||(d||bd(!1)+"")+"/j/collect";b=a.get(qe)||void 0;pe(e,P(a,Ra),b,a.Z(Ia));break;case "b":e=c||(d||bd(!1)+"")+"/.netlify/functions/ga";default:b?(c=P(a,Ra),d=(d=a.Z(Ia))||ua,"image"==b?wc(e,c,d):"xhr"==b&&wd(e,c,d)||"beacon"==b&&x(e,c,d)||ba(e,c,d)):ba(e,P(a,Ra),a.Z(Ia))}e=P(a,Na);e=h(e);b=e.hitcount;e.hitcount=b?b+1:1;e.first_hit||(e.first_hit=(new Date).getTime());e=P(a, +function Sa(a){var b=P(a,fa);!b&&a.get(Vd)&&(b="beacon");var c=P(a,gd),d=P(a,oe),e=c||(d||bd(!1)+"")+"/api/ga";switch(P(a,ad)){case "d":e=c||(d||bd(!1)+"")+"/j/collect";b=a.get(qe)||void 0;pe(e,P(a,Ra),b,a.Z(Ia));break;case "b":e=c||(d||bd(!1)+"")+"/api/ga";default:b?(c=P(a,Ra),d=(d=a.Z(Ia))||ua,"image"==b?wc(e,c,d):"xhr"==b&&wd(e,c,d)||"beacon"==b&&x(e,c,d)||ba(e,c,d)):ba(e,P(a,Ra),a.Z(Ia))}e=P(a,Na);e=h(e);b=e.hitcount;e.hitcount=b?b+1:1;e.first_hit||(e.first_hit=(new Date).getTime());e=P(a, Na);delete h(e).pending_experiments;a.set(Ia,ua,!0)}function Hc(a){qc().expId&&a.set(Nc,qc().expId);qc().expVar&&a.set(Oc,qc().expVar);var b=P(a,Na);if(b=h(b).pending_experiments){var c=[];for(d in b)b.hasOwnProperty(d)&&b[d]&&c.push(encodeURIComponent(d)+"."+encodeURIComponent(b[d]));var d=c.join("!")}else d=void 0;d&&((b=a.get(m))&&(d=b+"!"+d),a.set(m,d,!0))}function cd(){if(O.navigator&&"preview"==O.navigator.loadPurpose)throw"abort";} function yd(a){var b=O.gaDevIds||[];if(ka(b)){var c=a.get("&did");qa(c)&&0=c)throw"abort";a.set(Wa,--c)}a.set(Ua,++b)};var Ya=function(){this.data=new ee};Ya.prototype.get=function(a){var b=$a(a),c=this.data.get(a);b&&void 0==c&&(c=ea(b.defaultValue)?b.defaultValue():b.defaultValue);return b&&b.Z?b.Z(this,a,c):c};var P=function(a,b){a=a.get(b);return void 0==a?"":""+a},R=function(a,b){a=a.get(b);return void 0==a||""===a?0:Number(a)};Ya.prototype.Z=function(a){return(a=this.get(a))&&ea(a)?a:ua}; Ya.prototype.set=function(a,b,c){if(a)if("object"==typeof a)for(var d in a)a.hasOwnProperty(d)&&ab(this,d,a[d],c);else ab(this,a,b,c)};var ab=function(a,b,c,d){if(void 0!=c)switch(b){case Na:wb.test(c)}var e=$a(b);e&&e.o?e.o(a,b,c,d):a.data.set(b,c,d)};var ue=new ee,ve=[],bb=function(a,b,c,d,e){this.name=a;this.F=b;this.Z=d;this.o=e;this.defaultValue=c},$a=function(a){var b=ue.get(a);if(!b)for(var c=0;c _data/metadata.json; npm run build-ci" - functions = "functions/" - -[[plugins]] - package = "@netlify/plugin-functions-install-core" - -[[headers]] - for = "*.avif" - [headers.values] - Content-Type = "image/avif" - Content-Disposition = "inline" diff --git a/test/test-generic-post.js b/test/test-generic-post.js index 3f51bf1..3005ea5 100644 --- a/test/test-generic-post.js +++ b/test/test-generic-post.js @@ -84,7 +84,7 @@ describe("check build output for a generic post", () => { expect(noscript.length).to.be.greaterThan(0); let count = 0; for (let n of noscript) { - if (n.textContent.includes("/.netlify/functions/ga")) { + if (n.textContent.includes("/api/ga")) { count++; expect(n.textContent).to.contain(GA_ID); } diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f9ff1b1 --- /dev/null +++ b/vercel.json @@ -0,0 +1,78 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public,max-age=300" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + }, + { + "key": "X-XSS-Protection", + "value": "1; mode=block" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "n/a" + } + ] + }, + { + "source": "/new-on-the-web/(.*).html", + "headers": [ + { + "key": "X-Frame-Options", + "value": "ALLOW" + } + ] + }, + { + "source": "/img/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public,max-age=31536000,immutable" + } + ] + }, + { + "source": "/js/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public,max-age=31536000,immutable" + } + ] + }, + { + "source": "/fonts/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public,max-age=31536000,immutable" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" + } + ] + }, + { + "source": "/favicon.svg", + "headers": [ + { + "key": "Cache-Control", + "value": "public,max-age=3600" + } + ] + } + ] +}