Skip to content

Optional Spreadsheet-Based Response Caching Layer #400

Description

@euvel

Sorry for creating an issue, I added an optional spreadsheet-based caching layer to Code.gs that stores responses for public GET requests in a Google Sheet. On cache hit, it returns the stored status/headers/body without consuming a UrlFetchApp call. It uses MD5 hashing for keys and TextFinder for fast lookups instead of row-by-row scanning. Cache-eligible requests are determined by _canUseCache() which skips POSTs, requests with bodies, and anything with auth/cookie headers. The sheet caps at 5000 rows with FIFO eviction, and the whole feature is off by default unless CACHE_SPREADSHEET_ID is set. If the sheet is unavailable, it falls back to normal relay behavior.

const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";

const CACHE_SPREADSHEET_ID = "CHANGE_ME_TO_CACHE_SPREADSHEET_ID";
const CACHE_SHEET_NAME = "RelayCache";

const SKIP_HEADERS = {
  host: 1, connection: 1, "content-length": 1,
  "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
  "priority": 1, te: 1,
};

function doPost(e) {
  try {
    var req = JSON.parse(e.postData.contents);
    if (req.k !== AUTH_KEY) return _json({ e: "unauthorized" });
    if (Array.isArray(req.q)) return _doBatch(req.q);
    return _doSingle(req);
  } catch (err) {
    return _json({ e: String(err) });
  }
}

function _doSingle(req) {
  if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
    return _json({ e: "bad url" });
  }
  
  if (_canUseCache(req)) {
    var cached = _getFromCache(req.u);
    if (cached) {
      return _json({
        s: cached.status,
        h: JSON.parse(cached.headers),
        b: cached.body,
        cached: true
      });
    }
    
    var fetchResult = _fetchAndCache(req.u);
    if (fetchResult) {
      return _json({
        s: fetchResult.status,
        h: JSON.parse(fetchResult.headers),
        b: fetchResult.body,
        cached: false
      });
    }
  }
  
  var opts = _buildOpts(req);
  var resp = UrlFetchApp.fetch(req.u, opts);
  return _json({
    s: resp.getResponseCode(),
    h: _respHeaders(resp),
    b: Utilities.base64Encode(resp.getContent()),
  });
}

function _doBatch(items) {
  var fetchArgs = [];
  var errorMap = {};
  
  for (var i = 0; i < items.length; i++) {
    var item = items[i];
    if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
      errorMap[i] = "bad url";
      continue;
    }
    var opts = _buildOpts(item);
    opts.url = item.u;
    fetchArgs.push({ _i: i, _o: opts });
  }
  
  var responses = [];
  if (fetchArgs.length > 0) {
    responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; }));
  }
  
  var results = [];
  var rIdx = 0;
  for (var i = 0; i < items.length; i++) {
    if (errorMap.hasOwnProperty(i)) {
      results.push({ e: errorMap[i] });
    } else {
      var resp = responses[rIdx++];
      results.push({
        s: resp.getResponseCode(),
        h: _respHeaders(resp),
        b: Utilities.base64Encode(resp.getContent()),
      });
    }
  }
  return _json({ q: results });
}

function _buildOpts(req) {
  var opts = {
    method: (req.m || "GET").toLowerCase(),
    muteHttpExceptions: true,
    followRedirects: req.r !== false,
    validateHttpsCertificates: true,
    escaping: false,
  };
  if (req.h && typeof req.h === "object") {
    var headers = {};
    for (var k in req.h) {
      if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
        headers[k] = req.h[k];
      }
    }
    opts.headers = headers;
  }
  if (req.b) {
    opts.payload = Utilities.base64Decode(req.b);
    if (req.ct) opts.contentType = req.ct;
  }
  return opts;
}

function _respHeaders(resp) {
  try {
    if (typeof resp.getAllHeaders === "function") {
      return resp.getAllHeaders();
    }
  } catch (err) {}
  return resp.getHeaders();
}

function doGet(e) {
  return HtmlService.createHtmlOutput(
    "<!DOCTYPE html><html><head><title>My App</title></head>" +
      '<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
      "<h1>Welcome</h1><p>This application is running normally.</p>" +
      '<p>Cache active. Spreadsheet ID: ' + CACHE_SPREADSHEET_ID + '</p>' +
      "</body></html>"
  );
}

function _json(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
    ContentService.MimeType.JSON
  );
}

function _initCacheSheet() {
  if (CACHE_SPREADSHEET_ID === "YOUR_SPREADSHEET_ID_HERE") {
    return null;
  }
  try {
    var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID);
    var sheet = ss.getSheetByName(CACHE_SHEET_NAME);
    if (!sheet) {
      sheet = ss.insertSheet(CACHE_SHEET_NAME);
      sheet.getRange(1, 1, 1, 6).setValues([[
        "URL_Hash", "URL", "Status", "Headers", "Body", "Timestamp"
      ]]);
    }
    return sheet;
  } catch(e) {
    return null;
  }
}

function _getUrlHash(url) {
  var rawHash = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, url);
  return rawHash.map(function(byte) {
    var v = (byte < 0) ? 256 + byte : byte;
    return ("0" + v.toString(16)).slice(-2);
  }).join("");
}

function _canUseCache(req) {
  if ((req.m || "GET") !== "GET") return false;
  if (req.b) return false;
  if (!req.u || !req.u.match(/^https?:\/\//i)) return false;
  if (CACHE_SPREADSHEET_ID === "YOUR_SPREADSHEET_ID_HERE") return false;
  
  if (req.h && typeof req.h === "object") {
    var cacheBustingHeaders = ["authorization", "cookie", "x-api-key"];
    for (var k in req.h) {
      if (cacheBustingHeaders.indexOf(k.toLowerCase()) !== -1) {
        return false;
      }
    }
  }
  
  return true;
}

function _getFromCache(url) {
  var sheet = _initCacheSheet();
  if (!sheet) return null;

  var hash = _getUrlHash(url);
  var finder = sheet.createTextFinder(hash).matchEntireCell(true);
  var found = finder.findNext();

  if (found) {
    var row = sheet.getRange(found.getRow(), 1, 1, 6).getValues()[0];
    return {
      status: row[2],
      headers: row[3],
      body: row[4]
    };
  }
  return null;
}

function _fetchAndCache(url) {
  var sheet = _initCacheSheet();
  if (!sheet) return null;
  
  try {
    var response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    var status = response.getResponseCode();
    var headers = _respHeaders(response);
    var body = Utilities.base64Encode(response.getContent());
    var hash = _getUrlHash(url);
    var timestamp = new Date().toISOString();
    
    sheet.appendRow([hash, url, status, JSON.stringify(headers), body, timestamp]);
    
    var rowCount = sheet.getDataRange().getNumRows();
    if (rowCount > 5000) {
      sheet.deleteRows(2, rowCount - 5000);
    }
    
    return { status: status, headers: JSON.stringify(headers), body: body };
  } catch(e) {
    return null;
  }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions