Typed offscreen document creation and messaging for Chrome MV3 extensions — DOM parsing, audio, canvas, and more. Part of @zovo/webext.
Chrome Manifest V3 removed DOM access from service workers. Service workers in extensions can no longer:
- Parse HTML/XML documents
- Play audio or video
- Use Canvas API for image manipulation
- Access the clipboard directly
- Use Web Workers for heavy computation
- Access geolocation
- Use localStorage or sessionStorage
Offscreen documents are the solution. They're hidden browser contexts that live alongside your service worker and provide full DOM capabilities. Use them as a bridge to perform DOM-heavy operations from your background script.
- ✅ Type-safe API — Full TypeScript support with typed messages
- ✅ Create offscreen documents — Simple configuration with justification reasons
- ✅ Bi-directional messaging — Send messages and receive responses (including async)
- ✅ Auto-close support — Clean up when done
- ✅ All MV3 reasons supported — AUDIO_PLAYBACK, CLIPBOARD, DOM_PARSER, WORKERS, and more
- ✅ Singleton helper pattern — Reusable offscreen helpers for clean code
- ✅ Lightweight — No dependencies, tiny bundle size
npm install @theluckystrike/webext-offscreenOr with pnpm:
pnpm add @theluckystrike/webext-offscreenFirst, add offscreen.html to your extension:
<!DOCTYPE html>
<html>
<head>
<script src="offscreen.js"></script>
</head>
<body></body>
</html>import { ensureOffscreen, sendToOffscreen } from "webext-offscreen";
// Create the offscreen document
await ensureOffscreen({
url: "offscreen.html",
reasons: ["DOM_PARSER"],
justification: "Parse HTML content from external sources",
});
// Send a message and get a response
const result = await sendToOffscreen("parse", {
html: "<div><h1>Hello World</h1></div>"
});
console.log(result.text); // "Hello World"import { onOffscreenMessage, setupOffscreenListener } from "webext-offscreen";
// Register handlers
onOffscreenMessage("parse", (data) => {
const parser = new DOMParser();
const doc = parser.parseFromString(data.html, "text/html");
return { text: doc.body.textContent };
});
// Async handlers are supported too
onOffscreenMessage("fetch", async (data) => {
const response = await fetch(data.url);
const json = await response.json();
return json;
});
// Start listening
setupOffscreenListener();// Background script
const offscreen = createOffscreenHelper({
url: "offscreen.html",
reasons: ["DOM_PARSER"],
justification: "Parse HTML content from API responses",
});
await offscreen.ensure();
// Extract data from HTML
const productData = await offscreen.send("extract-product", {
html: responseText,
});
// Result: { title: "...", price: "...", description: "..." }
await offscreen.close();// Play audio from background without popup
await ensureOffscreen({
url: "offscreen.html",
reasons: ["AUDIO_PLAYBACK"],
justification: "Play notification sounds",
});
await sendToOffscreen("play-audio", {
src: "/sounds/notification.mp3",
volume: 0.8,
});// Process images in offscreen document
await ensureOffscreen({
url: "offscreen.html",
reasons: ["BLOBS"],
justification: "Resize and optimize images",
});
const processed = await sendToOffscreen("process-image", {
imageData: imageArrayBuffer,
width: 800,
height: 600,
format: "image/png",
});// Read/write clipboard from background
await ensureOffscreen({
url: "offscreen.html",
reasons: ["CLIPBOARD"],
justification: "Copy extracted data to clipboard",
});
await sendToOffscreen("copy-to-clipboard", {
text: "Copied from extension!",
});// Use Web Workers for CPU-intensive tasks
await ensureOffscreen({
url: "offscreen.html",
reasons: ["WORKERS"],
justification: "Process large datasets",
});
const result = await sendToOffscreen("process-data", {
records: largeDataset,
operation: "aggregate",
});For cleaner code, use the helper pattern:
import { createOffscreenHelper } from "webext-offscreen";
const parser = createOffscreenHelper({
url: "offscreen.html",
reasons: ["DOM_PARSER"],
justification: "Parse HTML content",
});
// Use throughout your extension
async function parseHTML(html: string) {
await parser.ensure();
return parser.send("parse", { html });
}
async function closeParser() {
await parser.close();
}| Function | Description | Returns |
|---|---|---|
ensureOffscreen(config) |
Create offscreen document if it doesn't exist | Promise<void> |
hasOffscreen() |
Check if an offscreen document is currently active | Promise<boolean> |
closeOffscreen() |
Close the active offscreen document | Promise<void> |
sendToOffscreen(type, data) |
Send a typed message to the offscreen document | Promise<T> |
createOffscreenHelper(config) |
Create a reusable helper object | OffscreenHelper |
| Method | Description | Returns |
|---|---|---|
ensure() |
Ensure the offscreen document exists | Promise<void> |
close() |
Close the offscreen document | Promise<void> |
isActive() |
Check if document is active | Promise<boolean> |
send(type, data) |
Send a message | Promise<T> |
| Function | Description |
|---|---|
onOffscreenMessage(type, handler) |
Register a message handler for a specific type |
setupOffscreenListener() |
Start listening for incoming messages |
removeHandler(type) |
Remove a specific message handler |
clearHandlers() |
Remove all registered handlers |
These are the valid reasons for creating an offscreen document (as per Chrome's API):
"TESTING" | "AUDIO_PLAYBACK" | "BLOBS" | "CLIPBOARD" | "DOM_PARSER"
| "DOM_SCRAPING" | "GEOLOCATION" | "LOCAL_STORAGE" | "MATCH_MEDIA" | "WORKERS"interface OffscreenConfig {
url: string;
reasons: OffscreenReason[];
justification: string;
}
interface OffscreenMessage<T = unknown> {
target: "offscreen";
type: string;
data: T;
}
interface OffscreenResponse<T = unknown> {
type: string;
data: T;
}- Chrome (or Chromium-based browser) with Manifest V3 support
- Node.js 18+ for development
Part of the @zovo/webext ecosystem:
- webext-storage — Typed storage wrapper
- webext-messenger — Type-safe messaging
- webext-manifest — Manifest utilities
MIT
Built by theluckystrike — zovo.one