Skip to content

Commit c4cb79b

Browse files
Keep service worker alive via content script heartbeat
Chrome MV3 service workers terminate after 30s of inactivity — setInterval in the SW doesn't prevent it. Move the keepalive heartbeat from background.js to StreamDeckConnectionMananger in the content script. Any Port message resets the SW idle timer (Chrome 114+), keeping the worker alive indefinitely while Meet is open. Also fix onerror to schedule reconnect when ports still exist (was silently dropping the connection on socket errors), and extract _scheduleReconnect() to deduplicate reconnect logic.
1 parent acd14d2 commit c4cb79b

3 files changed

Lines changed: 70 additions & 35 deletions

File tree

browser-extension/background.js

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
const STREAM_DECK_PORT = 2394;
33

44
const RECONNECTION_INTERVAL_SECS = 2;
5-
const HEARTBEAT_INTERVAL_SECS = 20;
6-
const HEARTBEAT_EVENT_NAME = "keepAlive";
75
const WEBSOCKET_OPEN_STATE = 1;
86
const PORT_NAME = "streamdeck-googlemeet";
97

@@ -23,18 +21,13 @@ class StreamDeckBackgroundBridge {
2321
webSocketFactory = (url) => new WebSocket(url),
2422
setTimeoutFn = setTimeout,
2523
clearTimeoutFn = clearTimeout,
26-
setIntervalFn = setInterval,
27-
clearIntervalFn = clearInterval,
2824
} = {}) {
2925
this._webSocketFactory = webSocketFactory;
3026
this._setTimeout = setTimeoutFn;
3127
this._clearTimeout = clearTimeoutFn;
32-
this._setInterval = setIntervalFn;
33-
this._clearInterval = clearIntervalFn;
3428

3529
this._ports = new Set();
3630
this._socket = null;
37-
this._heartbeatIntervalId = null;
3831
this._reconnectTimeoutId = null;
3932
}
4033

@@ -107,24 +100,20 @@ class StreamDeckBackgroundBridge {
107100
event
108101
);
109102
this._closeSocket();
103+
if (this._ports.size) {
104+
this._scheduleReconnect();
105+
}
110106
};
111107

112108
this._socket.onclose = () => {
113109
this._socket = null;
114-
this._stopHeartbeat();
115110

116-
if (!this._ports.size) {
117-
return;
111+
if (this._ports.size) {
112+
this._scheduleReconnect();
118113
}
119-
120-
this._reconnectTimeoutId = this._setTimeout(() => {
121-
this._reconnectTimeoutId = null;
122-
this._createWebsocket();
123-
}, RECONNECTION_INTERVAL_SECS * 1000);
124114
};
125115

126116
this._socket.onopen = () => {
127-
this._startHeartbeat();
128117
this._broadcast({
129118
type: MESSAGE_TYPES.STREAM_DECK_CONNECTION_OPENED,
130119
});
@@ -162,18 +151,12 @@ class StreamDeckBackgroundBridge {
162151
});
163152
}
164153

165-
_startHeartbeat = () => {
166-
this._stopHeartbeat();
167-
this._heartbeatIntervalId = this._setInterval(() => {
168-
this._sendToSocket({ event: HEARTBEAT_EVENT_NAME });
169-
}, HEARTBEAT_INTERVAL_SECS * 1000);
170-
}
171-
172-
_stopHeartbeat = () => {
173-
if (this._heartbeatIntervalId) {
174-
this._clearInterval(this._heartbeatIntervalId);
175-
this._heartbeatIntervalId = null;
176-
}
154+
_scheduleReconnect = () => {
155+
this._stopReconnects();
156+
this._reconnectTimeoutId = this._setTimeout(() => {
157+
this._reconnectTimeoutId = null;
158+
this._createWebsocket();
159+
}, RECONNECTION_INTERVAL_SECS * 1000);
177160
}
178161

179162
_stopReconnects = () => {
@@ -184,7 +167,6 @@ class StreamDeckBackgroundBridge {
184167
}
185168

186169
_closeSocket = () => {
187-
this._stopHeartbeat();
188170
if (this._socket) {
189171
const socket = this._socket;
190172
this._socket = null;

browser-extension/stream_deck_connection_manager.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const RECONNECTION_INTERVAL_SECS = 2;
2+
const HEARTBEAT_INTERVAL_SECS = 20;
3+
const HEARTBEAT_EVENT_NAME = "keepAlive";
24
const EXTENSION_PORT_NAME = "streamdeck-googlemeet";
35
const MESSAGE_TYPES = Object.freeze({
46
BROWSER_EVENT: "browserEvent",
@@ -15,6 +17,7 @@ class StreamDeckConnectionMananger {
1517
constructor() {
1618
this._port = null;
1719
this._reconnectTimeoutId = null;
20+
this._heartbeatIntervalId = null;
1821

1922
// Any SDEventHandlers registered to receive inbound events from the Stream Deck.
2023
this._eventHandlers = [];
@@ -65,6 +68,12 @@ class StreamDeckConnectionMananger {
6568

6669
this._port = chrome.runtime.connect({ name: EXTENSION_PORT_NAME });
6770

71+
// Send a message every 20s. Any message over a Port resets the service worker's
72+
// 30-second idle timer, keeping the worker alive as long as Meet is open.
73+
this._heartbeatIntervalId = setInterval(() => {
74+
this.sendMessage({ event: HEARTBEAT_EVENT_NAME });
75+
}, HEARTBEAT_INTERVAL_SECS * 1000);
76+
6877
this._port.onMessage.addListener((message) => {
6978
if (message?.type === MESSAGE_TYPES.STREAM_DECK_CONNECTION_OPENED) {
7079
this._attemptStateTransmission();
@@ -75,8 +84,13 @@ class StreamDeckConnectionMananger {
7584

7685
this._port.onDisconnect.addListener(() => {
7786
this._port = null;
87+
if (this._heartbeatIntervalId) {
88+
clearInterval(this._heartbeatIntervalId);
89+
this._heartbeatIntervalId = null;
90+
}
7891
if (this._reconnectTimeoutId) {
7992
clearTimeout(this._reconnectTimeoutId);
93+
this._reconnectTimeoutId = null;
8094
}
8195
this._reconnectTimeoutId = setTimeout(() => {
8296
this._reconnectTimeoutId = null;

browser-extension/tests/background.test.js

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,6 @@ test("broadcasts connection-opened and inbound websocket events to content scrip
5454
const socket = makeSocket();
5555
const bridge = new StreamDeckBackgroundBridge({
5656
webSocketFactory: () => socket,
57-
setIntervalFn: () => 1,
58-
clearIntervalFn: () => {},
5957
setTimeoutFn: () => 1,
6058
clearTimeoutFn: () => {},
6159
});
@@ -81,8 +79,6 @@ test("forwards browser events from content scripts to the websocket", () => {
8179
const socket = makeSocket();
8280
const bridge = new StreamDeckBackgroundBridge({
8381
webSocketFactory: () => socket,
84-
setIntervalFn: () => 1,
85-
clearIntervalFn: () => {},
8682
setTimeoutFn: () => 1,
8783
clearTimeoutFn: () => {},
8884
});
@@ -105,8 +101,6 @@ test("closes the websocket when the last content script disconnects", () => {
105101
const socket = makeSocket();
106102
const bridge = new StreamDeckBackgroundBridge({
107103
webSocketFactory: () => socket,
108-
setIntervalFn: () => 1,
109-
clearIntervalFn: () => {},
110104
setTimeoutFn: () => 1,
111105
clearTimeoutFn: () => {},
112106
});
@@ -119,3 +113,48 @@ test("closes the websocket when the last content script disconnects", () => {
119113

120114
assert.equal(socket.closeCalls, 1);
121115
});
116+
117+
test("schedules a reconnect when the websocket errors", () => {
118+
let timeoutsScheduled = 0;
119+
const socket = makeSocket();
120+
const bridge = new StreamDeckBackgroundBridge({
121+
webSocketFactory: () => socket,
122+
setTimeoutFn: () => {
123+
timeoutsScheduled += 1;
124+
return 1;
125+
},
126+
clearTimeoutFn: () => {},
127+
});
128+
const port = makePort();
129+
130+
bridge.addPort(port);
131+
socket.readyState = 1;
132+
133+
// Simulate an error
134+
socket.onerror(new Error("socket error"));
135+
136+
assert.equal(socket.closeCalls, 1);
137+
assert.equal(timeoutsScheduled, 1);
138+
});
139+
140+
test("schedules a reconnect when the websocket closes", () => {
141+
let timeoutsScheduled = 0;
142+
const socket = makeSocket();
143+
const bridge = new StreamDeckBackgroundBridge({
144+
webSocketFactory: () => socket,
145+
setTimeoutFn: () => {
146+
timeoutsScheduled += 1;
147+
return 1;
148+
},
149+
clearTimeoutFn: () => {},
150+
});
151+
const port = makePort();
152+
153+
bridge.addPort(port);
154+
socket.readyState = 1;
155+
156+
socket.close();
157+
158+
assert.equal(socket.closeCalls, 1);
159+
assert.equal(timeoutsScheduled, 1);
160+
});

0 commit comments

Comments
 (0)