DEV Community

anders lee
anders lee

Posted on

"Shipping a data-heavy browser extension: offline tables, daily updates, no host permissions"

I maintain a reference site for steel section weights, and recently turned it into a browser extension: a weight calculator in the toolbar, a look-up for standard beam sizes such as W8x18 or IPE 200, and a right-click card that shows the weight of a size selected on any page. The extension is plain JavaScript with no build step: an HTML popup, a module service worker and one shared core.js.

The interesting part was not the UI but the data: 1,557 sections, pipe schedules, rebar sizes and sheet gauges, about 400 KB of JSON. Here is how it stays offline, current and easy to review in four stores.

Bundle a snapshot, check a tiny version file

The extension ships a copy of the data, so it works offline from the first second. Once a day it asks the site whether the data changed. The site serves two files:

  • /ext/data-v1.json: the full table (406 KB, 73 KB compressed)
  • /ext/version.json: {"v":"fa998f332312","bytes":406323,"url":"/ext/data-v1.json"}

The version is a content hash, computed when the site is built, so it changes exactly when the data changes and not on every deploy:

import { createHash } from "node:crypto";
export const version = createHash("sha256").update(json).digest("hex").slice(0, 12);
Enter fullscreen mode Exit fullscreen mode

The update itself is short. The extension downloads the big file only when the hash differs, and validates it before storing it:

export async function update() {
  const now = Date.now();
  await chrome.storage.local.set({ lastCheck: now });
  cache = null; // another context may have changed the stored copy
  const current = await loadData();
  const head = await (await fetch(VERSION_URL, { cache: "no-store" })).json();
  if (!head.v || head.v === current.v) return { updated: false, v: current.v };
  const fresh = await (await fetch(DATA_URL, { cache: "no-store" })).json();
  if (!valid(fresh) || fresh.v !== head.v) throw new Error("bad data file");
  await chrome.storage.local.set({ data: fresh, updatedAt: now });
  cache = fresh;
  return { updated: true, v: fresh.v };
}
Enter fullscreen mode Exit fullscreen mode

A chrome.alarms alarm runs it every 24 hours, and onStartup runs it if the last check is older than 20 hours. When the extension itself is updated, the downloaded copy is dropped, because the new package always ships data at least as new.

Two details bit me. First, the popup and the service worker each keep an in-memory cache, so the worker must forget its copy before comparing versions, or a download done from the popup looks like "no change". Second, chrome.storage.local holds 10 MB by default, which is plenty for 400 KB, but localStorage would not be.

No host permissions: let the server allow the request

My first upload asked for https://steelweight-calculator.com/* as a host permission, only to download those two files. The Chrome Web Store warned that host permissions may trigger an in-depth review and delay publishing.

The extension does not need it. A request from an extension page or service worker to a server that sends Access-Control-Allow-Origin succeeds without any host permission. The site is static on Cloudflare, so one rule in _headers was enough:

/ext/*
  Access-Control-Allow-Origin: *
Enter fullscreen mode Exit fullscreen mode

After removing host_permissions, the permission list became storage, alarms, contextMenus, activeTab and scripting. The install prompt no longer says the extension can "read and change your data" on a website, which also matters for users.

A right-click card without reading the page

The right-click item uses activeTab: the user's click grants access to that one tab, once. The service worker finds the size in the selected text (it gets info.selectionText, so it never reads the page), then injects a function with the result as an argument:

await chrome.scripting.executeScript({
  target: { tabId: tab.id, frameIds: [info.frameId || 0] },
  func: showCard,
  args: [card],
});
Enter fullscreen mode Exit fullscreen mode

showCard builds the card with DOM methods inside a closed shadow root, so page CSS cannot break it and no HTML string is injected. Some pages refuse injection (the PDF viewer, chrome:// pages, the store itself), so the catch opens the popup in a small window with the query instead.

Ten languages, and a language switch

chrome.i18n follows the browser language and cannot be overridden. Users wanted to pick the language, so the popup loads _locales/<lang>/messages.json with fetch when a language is chosen and falls back to chrome.i18n.getMessage otherwise. The strings live in one Python file that writes all ten messages.json files and checks that every key used in the code exists.

Arabic needed one CSS line: numbers with units such as 24.17 kg/m came out reversed inside right-to-left text until the value cells got unicode-bidi: plaintext.

One folder, four stores

A build script zips the same folder three ways:

  • Chrome and Edge take the folder as it is.
  • Firefox wants an event page instead of a service worker, an add-on id and a data-collection declaration:
"background": { "scripts": ["background.js"], "type": "module" },
"browser_specific_settings": {
  "gecko": {
    "id": "extension@steelweight-calculator.com",
    "strict_min_version": "128.0",
    "data_collection_permissions": { "required": ["none"] }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Opera rejected the manifest with "__MSG_extShortName__ is too long": it checks the raw placeholder string before translating it. short_name is optional, so the Opera build simply drops it.

The rejection: "Keyword Spam"

Chrome rejected the first version for keyword spam. The Chinese and Spanish store descriptions listed shapes and standards as comma-separated runs, which read like stuffing even though every word was accurate. I rewrote all ten descriptions as plain sentences with one or two examples each, and the next version passed. If your product has many supported formats or standards, describe what a user does with them instead of listing them.

Testing

Playwright can load an unpacked extension in Chromium, including headless:

const ctx = await chromium.launchPersistentContext(profileDir, {
  channel: "chromium", headless: true,
  args: [`--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`],
});
const sw = ctx.serviceWorkers()[0] || await ctx.waitForEvent("serviceworker");
const popup = `chrome-extension://${sw.url().split("/")[2]}/popup.html`;
Enter fullscreen mode Exit fullscreen mode

The tests open the popup URL as a page, check a set of known weights, switch through all ten languages looking for untranslated keys or overflowing text, and render the right-click card on a sample page. A second script uses the same setup to make the store screenshots in every language.

The extension and its store links are here: steelweight-calculator.com/browser-extension.

Top comments (0)