Skip to main content

2 posts tagged with "Chrome Extension"

View all tags

Chrome Extension Writing Test Data to Production? Add a DRY-RUN Switch

ยท 3 min read

TL;DRโ€‹

Chrome extensions submitting collected data via API write directly to the production database โ€” even during development and testing. A 3-layer DRY-RUN switch solves this: set an env variable in .env.development โ†’ client reads it and adds an X-Dry-Run header โ†’ server intercepts the header and returns a data preview without writing. Production never sets the variable, so it's completely unaffected.


Problemโ€‹

A Chrome extension collects e-commerce data and submits it via API. During development, every test run writes a record to the production database. After a few rounds of testing, the database is full of dirty data that corrupts production analytics.

There's no "preview only" switch โ€” either comment out the submission code (easy to forget reverting) or accept dirty data in production.


Root Causeโ€‹

The Chrome extension shares the same API endpoint for both development and production. The fetch request has no marker to distinguish "this is a test submission" from "this is a real one." The backend processes all requests identically โ€” write to the database.

What's needed: a "preview mode" that shows what data would be submitted without actually writing it.


Solutionโ€‹

Three layers: environment variable โ†’ HTTP header โ†’ server-side interception.

Step 1: Declare the env variable typeโ€‹

// client/src/env.d.ts
interface ImportMetaEnv {
// ... other variables
readonly VITE_INQUIRY_DRY_RUN?: string;
}

Step 2: Development environment configโ€‹

# client/.env.development (development only)
VITE_INQUIRY_DRY_RUN=true
# client/.env.production (do NOT set this variable)
# Production always uses real writes

Step 3: Client reads env, conditionally adds headerโ€‹

In the Service Worker (background script):

// background.ts
chrome.storage.local.get('sessionToken', (result) => {
const sessionToken = result.sessionToken;
if (!sessionToken) return;

// Read DRY-RUN switch
const dryRun = import.meta.env.VITE_INQUIRY_DRY_RUN === 'true';

const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`,
};

// Add marker header in DRY-RUN mode
if (dryRun) {
headers['X-Dry-Run'] = 'true';
}

fetch(`${baseURL}/inquiry/collect`, {
method: 'POST',
headers,
body: JSON.stringify({ memberId, rows }),
});
});

WXT/Vite inlines import.meta.env.VITE_INQUIRY_DRY_RUN at build time. Dev build (.env.development) gets "true", production build gets undefined, so === 'true' naturally evaluates to false.

Step 4: Server intercepts DRY-RUN requestsโ€‹

// server/src/functions/analytics/inquiry.ts
router.post('/collect', async (req, res) => {
// ... auth, memberId mapping, etc.

const dryRun = req.headers['x-dry-run'] === 'true';

if (dryRun) {
// Skip database write, return preview
return res.json({
code: 200,
message: 'dry-run ok',
data: {
dryRun: true,
shop_id: match.shop_id,
platform_id: match.platform_id,
memberId,
rowCount: rows.length,
sampleRows: rows.slice(0, 3),
},
timestamp: Date.now(),
});
}

// Normal flow: write to database
const result = await analyticsClient.post('/internal/data/import/inquiry', payload);
res.json({ code: 200, data: result });
});

In DRY-RUN mode, the server still runs auth and validation (ensuring data format is correct) โ€” it only skips the final database write. This lets you verify both data format and permissions without producing dirty data.


Important Notesโ€‹

Never set DRY-RUN in production .env

.env.production should NOT set VITE_INQUIRY_DRY_RUN. In production builds, the variable is undefined and the condition naturally evaluates to false. Never enable this switch in production config.

Client must check the dryRun flag

DRY-RUN mode returns HTTP 200 with { dryRun: true, data: {...} }. Client code that only checks code === 200 will mistakenly treat it as a successful write. Always check response.data.dryRun to distinguish preview from actual submission.

DRY-RUN should still run auth and validation

Don't intercept DRY-RUN requests before authentication. Keep the full request chain (auth โ†’ validate โ†’ intercept) so you can verify request format and permissions โ€” just skip the final write step.


Chrome Extension Service Worker Can't Read Login Token? Cross-Context Token Sync

ยท 3 min read

TL;DRโ€‹

Chrome extension uses a sidepanel as the UI. User logs in, token goes to localStorage. But the Service Worker (background script) has no localStorage โ€” calling it throws ReferenceError. Fix: after login, send the token to the Service Worker via chrome.runtime.sendMessage, which writes it to chrome.storage.local. Sidepanel reads localStorage, Service Worker reads chrome.storage.local.


Problemโ€‹

User logs in successfully in the sidepanel. localStorage has sessionToken. But when the Service Worker (background script) tries to read the token for API requests:

ReferenceError: localStorage is not defined
at getAuthToken (background.js:42)
at submitCollectedData (background.js:87)

Login works fine in the sidepanel, but all authenticated requests from the Service Worker fail.


Root Causeโ€‹

Chrome extensions have multiple execution contexts, each with different APIs available:

ContextwindowlocalStoragechrome.storagechrome.runtime
Sidepanel / PopupYesYesMaybe undefinedYes
Content ScriptYesYes (isolated)YesYes
Service WorkerNoNoYesYes

Key constraints:

  • Service Worker has no window, document, or localStorage โ€” only chrome.storage API
  • Sidepanel in DevTools context may have chrome.storage as undefined

Login stores the token in localStorage (sidepanel context). Service Worker tries to read from localStorage โ†’ ReferenceError.


Solutionโ€‹

Architectureโ€‹

Sidepanel (login)
โ”œโ†’ localStorage.setItem(token) โ† sidepanel reads
โ””โ†’ chrome.runtime.sendMessage(syncToken)
โ””โ†’ Service Worker
โ””โ†’ chrome.storage.local.set(token) โ† Service Worker reads

Token lives in two places: localStorage (sidepanel reads) and chrome.storage.local (Service Worker reads). Login and logout keep both in sync via messaging.

Step 1: Login function โ€” dual writeโ€‹

After successful login, write to localStorage (sidepanel context), then sync to Service Worker:

// auth.ts โ€” after successful login
localStorage.setItem('sessionToken', token);
localStorage.setItem('userInfo', JSON.stringify(user));
localStorage.setItem('subscriptionInfo', JSON.stringify(subscription));

// Sync to chrome.storage.local (via Service Worker)
try {
chrome.runtime.sendMessage({
action: 'syncToken',
token,
user,
subscription,
});
} catch (e) {
// Non-extension context (unit tests, regular web), ignore
}

The try/catch is necessary โ€” chrome.runtime.sendMessage throws in non-extension contexts.

Step 2: Service Worker โ€” message handlersโ€‹

In the background script, listen for messages and write token to chrome.storage.local:

// background.ts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Sync token: sidepanel โ†’ chrome.storage.local
if (request.action === 'syncToken') {
const { token, user, subscription } = request;
const data: Record<string, string> = {};
if (token) data.sessionToken = token;
if (user) data.userInfo = typeof user === 'string' ? user : JSON.stringify(user);
if (subscription)
data.subscriptionInfo =
typeof subscription === 'string' ? subscription : JSON.stringify(subscription);

chrome.storage.local.set(data, () => {
console.log('[syncToken] token synced to chrome.storage.local');
sendResponse({ success: true });
});
return true; // Required for async sendResponse
}

// Clear token on logout
if (request.action === 'clearToken') {
chrome.storage.local.remove(
['sessionToken', 'userInfo', 'subscriptionInfo'],
() => {
console.log('[clearToken] token cleared from chrome.storage.local');
sendResponse({ success: true });
}
);
return true;
}
});

Step 3: Service Worker reads token from chrome.storage.localโ€‹

// Before (throws in Service Worker):
const sessionToken = localStorage.getItem('sessionToken');

// After (correct approach):
chrome.storage.local.get('sessionToken', (result) => {
const sessionToken = result.sessionToken;
if (!sessionToken) {
console.warn('No session token found');
return;
}
// Make authenticated API request
fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${sessionToken}` },
});
});

Step 4: Logout clears bothโ€‹

// auth.ts
function clearAuthData(): void {
localStorage.removeItem('sessionToken');
localStorage.removeItem('userInfo');
localStorage.removeItem('subscriptionInfo');
try {
chrome.runtime.sendMessage({ action: 'clearToken' });
} catch (e) {
// Non-extension context, ignore
}
}

Important Notesโ€‹

Both stores must stay in sync

Token lives in two places: localStorage (sidepanel reads) + chrome.storage.local (Service Worker reads). Login and logout must update both โ€” otherwise states diverge.

return true in onMessage is mandatory for async

When using async sendResponse (e.g., inside chrome.storage.local.set callback), the onMessage listener must return true. Without it, the message channel closes immediately and sendResponse becomes a no-op.

Don't use chrome.storage directly in sidepanel

In DevTools context, chrome.storage may be undefined. Use localStorage + message forwarding as the reliable approach.