UNPKG

@greenweb/gaw-plugin-cloudflare-workers

Version:

This plugin provides some useful functions that can be used when setting up the Grid-aware Websites library using Cloudflare Workers

756 lines (667 loc) 27.5 kB
import { GridIntensity } from "@greenweb/grid-aware-websites"; /** * Type definitions * @typedef {import('./types').cloudflareRequest} cloudflareRequest The incoming request object. * @typedef {import('./types').locationOptions} locationOptions Additional options for the function. * @typedef {import('./types').locationResponse} locationResponse The location of the user. * @typedef {import('./types').cloudflareResponse} cloudflareResponse The response object to save. * @typedef {import('./types').cloudflareEnv} cloudflareEnv Cloudflare environment. * @typedef {import('./types').kvOptions} kvOptions Additional options for the function. * @typedef {import('./types').cloudflareContext} cloudflareContext Cloudflare Workers ExecutionContext */ // import { HTMLRewriter } from '@cloudflare/workers-types'; /** * Get the location of the user from the request object. * @param {cloudflareRequest} request The incoming request object. * @param {locationOptions} [options] Additional options for the function. * @returns {locationResponse} The location of the user. */ function getLocation(request, options) { const mode = options?.mode || "country"; const country = typeof request.cf?.country === "string" ? request.cf.country : undefined; if (mode === "latlon") { const lat = typeof request.cf?.latitude === "string" ? request.cf.latitude : undefined; const lon = typeof request.cf?.longitude === "string" ? request.cf.longitude : undefined; if (!lat || !lon) { return { status: "error", }; } return { status: "success", lat, lon, }; } if (!country) { return { status: "error", }; } return { status: "success", country, }; } /** * Save a page response to the KV store. * @param {cloudflareEnv} env Cloudflare environment. * @param {string} key The key to save the response under. We recommend using the URL as the key. * @param {Response} response The response object to save. * @param {kvOptions} [options] Additional options for the function.\ * @returns {Promise<void>} * @example * savePageToKv(env, "https://example.com/page", response); */ async function savePageToKv(env, key, response, options) { let expirationTtl = 60 * 60 * 24; if (options?.expirationTtl && typeof options?.expirationTtl === "number") { expirationTtl = options.expirationTtl; } try { const responseBody = await response.text(); await env.GAW_PAGE_KV.put(key, responseBody, { expirationTtl }); return Promise.resolve(); } catch { return Promise.reject(); } } /** * Fetch a page response from the KV store. * @param {cloudflareEnv} env Cloudflare environment. * @param {string} key The key to fetch the response from. We recommend using the URL as the key. * @return {Promise<string | Object | ArrayBuffer | ReadableStream | null>} The response object from the KV store. */ async function fetchPageFromKv(env, key) { return await env.GAW_PAGE_KV.get(key); } /** * Save electricity data to a KV store. * @param {cloudflareEnv} env Cloudflare environment * @param {string} key The key to save the response under. We recommend using either the country code or lat-lon values * @param {string | ArrayBuffer | ArrayBufferView | import('@cloudflare/workers-types').ReadableStream} data The data to be saved * @param {kvOptions} [options] Additional options for the function * @return {Promise<void>} */ async function saveDataToKv(env, key, data, options) { let expirationTtl = 60 * 60; // Default 1 hour if (options?.expirationTtl && typeof options?.expirationTtl === "number") { expirationTtl = options.expirationTtl; } return await env.GAW_DATA_KV.put(key, data, { expirationTtl }); } /** * Fetch electricity data from a KV store. * @param {cloudflareEnv} env Cloudflare environment. * @param {string} key The key to fetch the response for. We recommend using the country code or lat-lon values * @return {Promise<string | Object | ArrayBuffer | ReadableStream | null>} */ async function fetchDataFromKv(env, key) { return await env.GAW_DATA_KV.get(key); } /** * Automatically applies Grid Aware Website (GAW) modifications to incoming web requests based on grid data. * This function handles the core GAW implementation for Cloudflare Workers by checking location data, * fetching grid/power data, and conditionally modifying HTML responses. * * @param {cloudflareRequest} request - The incoming request object from Cloudflare. * @param {cloudflareEnv} env - The Cloudflare environment containing KV bindings and API keys. * @param {cloudflareContext} ctx - The Cloudflare Workers execution context. * @param {Object} [config={}] - Configuration options for GAW behavior. * @param {string[]} [config.contentType=['text/html']] - Content types to process. * @param {string[]} [config.ignoreRoutes=[]] - Routes to exclude from GAW processing. * @param {string} [config.ignoreGawCookie='gaw'] - Cookie name to disable GAW for specific users. * @param {boolean} [config.userOptIn=false] - Allows developers to specify if whether users are required to opt-in to the grid-aware website experience on their site. * @param {"latlon"|"country"} [config.locationType='latlon'] - Type of location data to use. * @param {Object} [config.htmlChanges=null] - An object to capture the different HTML changes that are applied at each different grid intesity level. * @param {Object} [config.htmlChanges.low=null] - Custom HTMLRewriter for page modification at low grid intensity level. * @param {Object} [config.htmlChanges.moderate=null] - Custom HTMLRewriter for page modification at moderate grid intensity level. * @param {Object} [config.htmlChanges.high=null] - Custom HTMLRewriter for page modification at high grid intensity level. * @param {null|'low'|'moderate'|'high'} [config.defaultView=null] - Default view for the grid-aware website experience. * @param {string} [config.gawDataApiKey=''] - API key for the data source. * @param {Object} [config.infoBar={}] - Configuration for the info bar element. * @param {string} [config.infoBar.target=''] - Target element for the info bar. * @param {string} [config.infoBar.version='latest'] - Version of the info bar to use. * @param {string} [config.infoBar.learnMoreLink='#'] - Link to learn more about the info bar. * @param {string} [config.infoBar.popoverText=''] - Provide a custom string of text to be used in the info bar popover element. * @param {string} [config.infoBar.customViews=''] - Custom views for the grid-aware website experience. * @param {boolean} [config.kvCacheData=false] - Whether to cache grid data in KV store. * @param {boolean} [config.kvCachePage=false] - Whether to cache modified pages in KV store. * @param {"none"|"full"|"headers"|"logs"} [config.debug="none"] - Activates debug mode which outputs logs and returns additional response headers. * @param {boolean} [config.dev=false] - Whether to enable development mode. * @param {Object} [config.devConfig=null] - Configuration for development mode. * @param {string} [config.devConfig.hostname=''] - Hostname for development mode. * @param {string} [config.devConfig.port=''] - Port for development mode. * @param {string} [config.devConfig.protocol=''] - Protocol for development mode. * @returns {Promise<Response>} A modified or unmodified response based on grid data and configuration. * @example * // Basic usage in a Cloudflare Worker * export default { * async fetch(request, env, ctx) { * return auto(request, env, ctx, { * gawDataApiKey: 'your-api-key' * }); * } * }; */ async function auto(request, env, ctx, config = {}) { // Only run function on GET requests if (request.method !== "GET") { //@ts-ignore return fetch(request); } const debug = config?.debug || "none"; let debugHeaders = {}; try { const contentType = config?.contentType || ["text/html"]; const ignoreRoutes = config?.ignoreRoutes || []; const ignoreGawCookie = config?.ignoreGawCookie || "gaw-ignore"; const htmlChanges = config?.htmlChanges || {}; const locationType = config.locationType || "latlon"; const devMode = config.dev || false; const devConfig = config.devConfig || {}; const userOptIn = config.userOptIn || false; const defaultView = config.defaultView || null; // We set this as an options object so that we can add keys to it later if we want to expand this function const gawOptions = {}; gawOptions.apiKey = config?.gawDataApiKey || ""; const infoBarOptions = {}; infoBarOptions.target = config?.infoBar.target || ""; infoBarOptions.learnMoreLink = config?.infoBar.learnMoreLink || "#"; infoBarOptions.version = config?.infoBar.version || "latest"; infoBarOptions.popoverText = config?.infoBar.popoverText || ""; infoBarOptions.customViews = config?.infoBar.customViews || ""; let dev = null; if (devMode) { console.log("Dev mode enabled"); const url = new URL(request.url); url.hostname = devConfig.hostname || "localhost"; url.port = devConfig.port || "8080"; url.protocol = devConfig.protocol || "http"; // @ts-ignore dev = new Request(url.toString(), request.clone()); } if (dev) { // @ts-ignore request = dev; } const url = request.url; // If the route we're working on is on the ignore list, bail out as well if (ignoreRoutes.some((route) => url.includes(route))) { // @ts-ignore return fetch(request.clone(), { redirect: "follow", }); } //@ts-ignore const response = await fetch(request.clone(), { redirect: "follow", }); const contentTypeHeader = response.headers.get("content-type"); // Then check if the request content type is HTML. // If the content is not HTML, then return the response without any changes. if (!contentTypeHeader) { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "no-content-type" }; } return new Response(response.body, { ...response, headers: { ...response.headers, "Content-Encoding": "gzip", ...debugHeaders, }, }); } // Check if the content type is in the list of content types to modify const isContentTypeValid = contentType.some((type) => contentTypeHeader.toLowerCase().includes(type.toLowerCase()), ); if (!isContentTypeValid) { if (debug === "full" || debug === "logs") { // If none of the content types match, return the response without changes. console.log( "Content type is not in the list, returning response as is", url, contentTypeHeader, contentType, ); } if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "skip-content-type" }; } return new Response(response.body, { ...response, headers: { ...response.headers, "Content-Type": contentTypeHeader, "Content-Encoding": "gzip", ...debugHeaders, }, }); } let rewriter = null; // We use a cookie to allow us to manually disable the grid-aware feature. // This is useful for testing purposes. It can also be used to disable the feature for specific users. const requestCookies = request.headers.get("cookie"); if (requestCookies && requestCookies.includes("gaw-manual-view")) { if (infoBarOptions.customViews.length > 0) { infoBarOptions.customViews.split(",").map((view) => { if ( requestCookies.includes( `gaw-manual-view=${view.trim().toLowerCase()}`, ) ) { rewriter = htmlChanges[view.trim().toLowerCase()]; } }); } else { if (requestCookies.includes("gaw-manual-view=low")) { rewriter = htmlChanges.low; } else if (requestCookies.includes("gaw-manual-view=moderate")) { rewriter = htmlChanges.moderate; } else if (requestCookies.includes("gaw-manual-view=high")) { rewriter = htmlChanges.high; } } if (infoBarOptions.target.length > 0) { rewriter.on("head", { element(element) { element.append( `<script type="module" src="https://esm.sh/@greenweb/gaw-info-bar@${infoBarOptions.version}"></script>`, { html: true }, ); }, }); rewriter.on(infoBarOptions.target, { element(element) { element.setInnerContent( `<gaw-info-bar ${infoBarOptions.customViews.length > 0 ? `data-views="${infoBarOptions.customViews}"` : ""} ${defaultView ? `data-default-view="${defaultView}"` : ""} data-learn-more-link=${infoBarOptions.learnMoreLink} ${infoBarOptions.popoverText.length > 0 ? `data-popover-text=${infoBarOptions.popoverText}` : ""}> </gaw-info-bar>`, { html: true }, ); }, }); } return new Response(rewriter.transform(response.clone()).body, { ...response, headers: { ...response.headers, "Content-Type": contentTypeHeader, "Content-Encoding": "gzip", ...debugHeaders, }, }); } if (userOptIn && !requestCookies?.includes("gaw-user-opt-in=true")) { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "user-opt-in" }; } if (infoBarOptions.customViews.length > 0) { infoBarOptions.customViews.split(",").map((view) => { if (defaultView.toLowerCase() === view.trim().toLowerCase()) { rewriter = htmlChanges[view.trim().toLowerCase()]; } else { //@ts-ignore rewriter = new HTMLRewriter(); } }); } else { if (defaultView === "low") { rewriter = htmlChanges.low; } else if (defaultView === "moderate") { rewriter = htmlChanges.moderate; } else if (defaultView === "high") { rewriter = htmlChanges.high; } else { //@ts-ignore rewriter = new HTMLRewriter(); } } if (infoBarOptions.target.length > 0) { rewriter.on("head", { element(element) { element.append( `<script type="module" src="https://esm.sh/@greenweb/gaw-info-bar@${infoBarOptions.version}"></script>`, { html: true }, ); }, }); rewriter.on(infoBarOptions.target, { element(element) { element.setInnerContent( `<gaw-info-bar ${infoBarOptions.customViews.length > 0 ? `data-views="${infoBarOptions.customViews}"` : ""} ${defaultView ? `data-default-view="${defaultView}"` : ""} data-learn-more-link=${infoBarOptions.learnMoreLink} ${infoBarOptions.popoverText.length > 0 ? `data-popover-text=${infoBarOptions.popoverText}` : ""}> </gaw-info-bar>`, { html: true }, ); }, }); } return new Response(rewriter.transform(response.clone()).body, { ...response, headers: { ...response.headers, "Content-Type": contentTypeHeader, "Content-Encoding": "gzip", "Set-Cookie": "gaw-user-opt-in=false; path=/; SameSite=lax;", ...debugHeaders, }, }); } if (requestCookies && requestCookies.includes(ignoreGawCookie)) { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "skip-cookie" }; } //@ts-ignore rewriter = new HTMLRewriter(); if (infoBarOptions.target.length > 0) { rewriter.on("head", { element(element) { element.append( `<script type="module" src="https://esm.sh/@greenweb/gaw-info-bar@${infoBarOptions.version}"></script>`, { html: true }, ); }, }); rewriter.on(infoBarOptions.target, { element(element) { element.setInnerContent( `<gaw-info-bar ${infoBarOptions.customViews.length > 0 ? `data-views="${infoBarOptions.customViews}"` : ""} ${defaultView ? `data-default-view="${defaultView}"` : ""} data-learn-more-link=${infoBarOptions.learnMoreLink} ${infoBarOptions.popoverText.length > 0 ? `data-popover-text=${infoBarOptions.popoverText}` : ""} data-ignore-cookie=${ignoreGawCookie}> </gaw-info-bar>`, { html: true }, ); }, }); } return new Response(rewriter.transform(response.clone()).body, { ...response, headers: { ...response.headers, "Content-Type": contentTypeHeader, "Content-Encoding": "gzip", ...debugHeaders, }, }); } // Get the location of the user const location = await getLocation(request, { mode: locationType, }); const { lat, lon, country } = location; // If the country data does not exist, then return the response without any changes. if (!country && (!lat || !lon)) { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "no-location-found" }; } if (debug === "full" || debug === "logs") { console.log("No location found."); } return new Response(response.body, { ...response, headers: { ...response.headers, "Content-Encoding": "gzip", ...debugHeaders, }, }); } if (lat && lon) { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-location": `lat: ${lat}, lon: ${lon}`, }; } if (debug === "full" || debug === "logs") { console.log(`Location: lat: ${lat}, lon: ${lon}`); } } else { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-location": `${country}` }; } if (debug === "full" || debug === "logs") { console.log(`Location: ${country}`); } } let gridData = {}; if (config?.kvCacheData) { let cachedData = ""; // Check if we have have cached grid data for the country if (lat && lon) { cachedData = await fetchDataFromKv(env, `${lat}_${lon}`); } else { cachedData = await fetchDataFromKv(env, country); } try { gridData = JSON.parse(cachedData); } catch { console.log("Error parsing KV data"); } if (gridData?.status) { if (debug === "full" || debug === "logs") { console.log("Using data from KV"); } if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-data-source": "workers-kv" }; } } } // If no cached data, fetch it using the PowerBreakdown class if (!gridData?.status) { if (debug === "full" || debug === "logs") { console.log("Using data from API"); } if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-data-source": "api" }; } const options = { mode: "level", apiKey: env.EMAPS_API_KEY || gawOptions.apiKey, }; const gridIntensity = new GridIntensity(options); if (lat && lon) { gridData = await gridIntensity.check({ lat, lon }); } else { gridData = await gridIntensity.check(country); } // If there's an error getting data, return the web page without any modifications if (gridData && "status" in gridData && gridData.status === "error") { if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "error-grid-data" }; } if (debug === "full" || debug === "logs") { console.log("Error getting grid data", gridData); } return new Response(response.body, { ...response, headers: { ...response.headers, "Content-Encoding": "gzip", ...debugHeaders, }, }); } if (config?.kvCacheData) { // Save the fresh data to KV for future use. // By default data is stored for 1 hour. if (lat && lon) { await saveDataToKv(env, `${lat}_${lon}`, JSON.stringify(gridData)); console.log("saved latlon to kv"); } else if (country) { await saveDataToKv(env, `${country}`, JSON.stringify(gridData)); console.log("saved country to kv"); } } } if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "auto", "gaw-grid-data": JSON.stringify(gridData), "gaw-location": JSON.stringify(location), }; } if (config?.kvCachePage) { // First, check if we've already got a cached response for the request URL. We do this using the Cloudflare Workers plugin. const cachedResponse = await fetchPageFromKv( env, `${gridData.level}_${request.url}`, ); // If there's a cached response, return it with the additional headers. if (debug === "full" || debug === "logs") { console.log("Using cached page from KV"); } if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-page-source": "workers kv" }; } if (cachedResponse) { return new Response(cachedResponse, { ...response, headers: { ...response.headers, "Content-Encoding": "gzip", "Content-Type": contentTypeHeader, ...debugHeaders, }, }); } } // If there's no cached response, we'll modify the HTML page. if (gridData.level === "low" && htmlChanges.low) { rewriter = htmlChanges.low; if (infoBarOptions.target.length > 0) { rewriter.on("head", { element(element) { element.append( `<script type="module" src="https://esm.sh/@greenweb/gaw-info-bar@${infoBarOptions.version}"></script>`, { html: true }, ); }, }); rewriter.on(infoBarOptions.target, { element(element) { element.setInnerContent( `<gaw-info-bar ${infoBarOptions.customViews.length > 0 ? `data-views="${infoBarOptions.customViews}"` : ""} ${defaultView ? `data-default-view="${defaultView}"` : ""} data-learn-more-link=${infoBarOptions.learnMoreLink} ${infoBarOptions.popoverText.length > 0 ? `data-popover-text=${infoBarOptions.popoverText}` : ""} data-gaw-level="low" data-gaw-location="${gridData.region}" data-learn-more-link=${infoBarOptions.learnMoreLink}> </gaw-info-bar>`, { html: true }, ); }, }); } } else if (gridData.level === "moderate" && htmlChanges.moderate) { rewriter = htmlChanges.moderate; if (infoBarOptions.target.length > 0) { rewriter.on("head", { element(element) { element.append( `<script type="module" src="https://esm.sh/@greenweb/gaw-info-bar@${infoBarOptions.version}"></script>`, { html: true }, ); }, }); rewriter.on(infoBarOptions.target, { element(element) { element.setInnerContent( `<gaw-info-bar ${infoBarOptions.customViews.length > 0 ? `data-views="${infoBarOptions.customViews}"` : ""} ${defaultView ? `data-default-view="${defaultView}"` : ""} data-learn-more-link=${infoBarOptions.learnMoreLink} ${infoBarOptions.popoverText.length > 0 ? `data-popover-text=${infoBarOptions.popoverText}` : ""} data-gaw-level="moderate" data-gaw-location="${gridData.region}" data-learn-more-link=${infoBarOptions.learnMoreLink}> </gaw-info-bar>`, { html: true }, ); }, }); } } else if (gridData.level === "high" && htmlChanges.high) { rewriter = htmlChanges.high; if (infoBarOptions.target.length > 0) { rewriter.on("head", { element(element) { element.append( `<script type="module" src="https://esm.sh/@greenweb/gaw-info-bar@${infoBarOptions.version}"></script>`, { html: true }, ); }, }); rewriter.on(infoBarOptions.target, { element(element) { element.setInnerContent( `<gaw-info-bar ${infoBarOptions.customViews.length > 0 ? `data-views="${infoBarOptions.customViews}"` : ""} ${defaultView ? `data-default-view="${defaultView}"` : ""} data-learn-more-link=${infoBarOptions.learnMoreLink} ${infoBarOptions.popoverText.length > 0 ? `data-popover-text=${infoBarOptions.popoverText}` : ""} data-gaw-level="high" data-gaw-location="${gridData.region}" data-learn-more-link=${infoBarOptions.learnMoreLink}> </gaw-info-bar>`, { html: true }, ); }, }); } } if (rewriter) { if (debug === "full" || debug === "logs") { console.log("Using HTMLRewriter"); } const gawResponse = new Response( rewriter.transform(response.clone()).body, { ...response, headers: { ...response.headers, "Content-Type": contentTypeHeader, "Content-Encoding": "gzip", ...debugHeaders, }, }, ); if (config?.kvCachePage) { // Store the modified response in the KV for 24 hours // We'll use the Cloudflare Workers plugin to perform this action. The plugin sets an expirationTtl of 24 hours by default, but this can be changed await savePageToKv( env, `${gridData.level}_${request.url}`, gawResponse.clone(), ); return gawResponse; } return gawResponse; } if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, "gaw-applied": "no-grid-aware" }; } // If the gridAware value is set to false, then return the response as is. return new Response(response.clone().body, { ...response, headers: { ...response.headers, "Content-Encoding": "gzip", ...debugHeaders, }, }); } catch (e) { console.log("Error in grid-aware auto function", e); // If there's an error getting data, return the web page without any modifications // console.log('Error getting grid data', e); if (debug === "full" || debug === "headers") { debugHeaders = { ...debugHeaders, ...debugHeaders, "gaw-applied": "error-failed", }; } // @ts-ignore const errorResponse = await fetch(request.clone(), { redirect: "follow", }); return new Response(errorResponse.body, { ...errorResponse, headers: { ...errorResponse.headers, "Content-Encoding": "gzip", ...debugHeaders, }, }); } } export default auto; export { getLocation, savePageToKv, fetchPageFromKv, saveDataToKv, fetchDataFromKv, };