diff --git a/package.json b/package.json index 7d009f6..4dd14ca 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "dev:web": "vite", "dev:background": "npm run build:background -- --mode development", "dev:js": "npm run build:js -- --mode development", - "build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js", - "build-firefox": "cross-env NODE_ENV=production EXTENSION=firefox run-s clear-firefox build:web build:prepare build:background build:js", + "build": "cross-env NODE_ENV=production run-s clear build:bridge build:web build:prepare build:background build:js", + "build-firefox": "cross-env NODE_ENV=production EXTENSION=firefox run-s clear-firefox build:bridge build:web build:prepare build:background build:js", + "build:bridge": "npm --prefix webext-bridge run build", "build:prepare": "esno scripts/prepare.ts", "build:background": "vite build --config vite.config.background.mts", "build:web": "vite build", @@ -34,6 +35,8 @@ "test:extension-runtime": "node scripts/verify-extension-runtime.mjs", "test:remote-endpoint": "esno scripts/test-remote-endpoint.ts", "test:production-endpoint": "node scripts/verify-production-endpoint.mjs", + "test:port-disconnect": "esno scripts/test-port-disconnect.ts", + "test:port-disconnect-build": "node scripts/verify-port-disconnect-build.mjs", "prepare": "husky" }, "devDependencies": { diff --git a/scripts/test-port-disconnect.ts b/scripts/test-port-disconnect.ts new file mode 100644 index 0000000..c02c80a --- /dev/null +++ b/scripts/test-port-disconnect.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { isBackForwardCachePortError } from '../webext-bridge/src/internal/port-disconnect'; + +assert.equal( + isBackForwardCachePortError( + 'The page keeping the extension port is moved into back/forward cache, so the message channel is closed.', + ), + true, +); +assert.equal(isBackForwardCachePortError('Extension context invalidated.'), false); +assert.equal(isBackForwardCachePortError(), false); + +console.log('Port disconnect tests passed.'); diff --git a/scripts/verify-port-disconnect-build.mjs b/scripts/verify-port-disconnect-build.mjs new file mode 100644 index 0000000..8f24501 --- /dev/null +++ b/scripts/verify-port-disconnect-build.mjs @@ -0,0 +1,16 @@ +import { readFile } from 'node:fs/promises'; + +const background = await readFile('extension/dist/background/index.mjs', 'utf8'); +const contentScript = await readFile('extension/dist/contentScripts/index.global.js', 'utf8'); + +if (!background.includes('lastError')) { + throw new Error('Background bundle does not consume runtime.lastError on port disconnect.'); +} +if (!contentScript.includes('back/forward cache')) { + throw new Error('Content script bundle does not recognize BFCache port closures.'); +} +if (!contentScript.includes('pagehide') || !contentScript.includes('pageshow')) { + throw new Error('Content script bundle does not suspend and resume the BFCache port lifecycle.'); +} + +console.log('Production BFCache port handling verification passed.'); diff --git a/webext-bridge/src/background.ts b/webext-bridge/src/background.ts index f05f53f..3548ad8 100644 --- a/webext-bridge/src/background.ts +++ b/webext-bridge/src/background.ts @@ -279,6 +279,10 @@ browser.runtime.onConnect.addListener((incomingPort) => { }); incomingPort.onDisconnect.addListener(() => { + // Reading lastError prevents Chrome from reporting an expected BFCache port closure as + // an unchecked runtime error in the extension service worker console. + void browser.runtime.lastError; + // sometimes previous content script's onDisconnect is called *after* the fresh content-script's // onConnect. So without this fingerprint equality check, we would remove the new port from map if (connMap.get(connArgs.endpointName)?.fingerprint === connArgs.fingerprint) diff --git a/webext-bridge/src/internal/persistent-port.ts b/webext-bridge/src/internal/persistent-port.ts index 29ae09e..28070d5 100644 --- a/webext-bridge/src/internal/persistent-port.ts +++ b/webext-bridge/src/internal/persistent-port.ts @@ -7,6 +7,7 @@ import { encodeConnectionArgs } from './connection-args'; import { createDeliveryLogger } from './delivery-logger'; import type { StatusMessage } from './port-message'; import { PortMessage } from './port-message'; +import { isBackForwardCachePortError } from './port-disconnect'; /** * Manfiest V3 extensions can have their service worker terminated at any point @@ -18,6 +19,8 @@ import { PortMessage } from './port-message'; export const createPersistentPort = (name = '') => { const fingerprint = createFingerprint(); let port: Runtime.Port; + let connected = false; + let inBackForwardCache = false; let undeliveredQueue: ReadonlyArray = []; const pendingResponses = createDeliveryLogger(); const onMessageListeners = new Set<(message: InternalMessage, port: Runtime.Port) => void>(); @@ -77,14 +80,28 @@ export const createPersistentPort = (name = '') => { }; const connect = () => { + if (connected || inBackForwardCache) return; + port = browser.runtime.connect({ name: encodeConnectionArgs({ endpointName: name, fingerprint, }), }); + connected = true; + const connectedPort = port; port.onMessage.addListener(handleMessage); - port.onDisconnect.addListener(connect); + port.onDisconnect.addListener(() => { + const disconnectMessage = browser.runtime.lastError?.message; + if (port !== connectedPort) return; + + connected = false; + if (isBackForwardCachePortError(disconnectMessage)) { + inBackForwardCache = true; + return; + } + connect(); + }); PortMessage.toBackground(port, { type: 'sync', @@ -95,6 +112,17 @@ export const createPersistentPort = (name = '') => { }); }; + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', (event) => { + if (event.persisted) inBackForwardCache = true; + }); + window.addEventListener('pageshow', (event) => { + if (!event.persisted) return; + inBackForwardCache = false; + connect(); + }); + } + connect(); return { diff --git a/webext-bridge/src/internal/port-disconnect.ts b/webext-bridge/src/internal/port-disconnect.ts new file mode 100644 index 0000000..0ae1bbd --- /dev/null +++ b/webext-bridge/src/internal/port-disconnect.ts @@ -0,0 +1,3 @@ +export function isBackForwardCachePortError(message?: string): boolean { + return message?.includes('back/forward cache') ?? false; +}