safari-mcp 2.17.1 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,6 +61,30 @@ async function _bridgeFetch(url, init = {}) {
61
61
  headers.set("X-Safari-MCP-Worker", _bridgeWorkerId);
62
62
  return fetch(url, { ...init, headers });
63
63
  }
64
+ const _CONTENT_WAKE_TOKEN_STORAGE_KEY = "mcpContentWakeTokenV1";
65
+ const _CONTENT_WAKE_LABEL = "safari-mcp-content-wakeup-v1";
66
+ async function _publishContentWakeToken() {
67
+ try {
68
+ const bridgeToken = await _bridgeAuthToken();
69
+ const encoder = new TextEncoder();
70
+ const key = await crypto.subtle.importKey(
71
+ "raw",
72
+ encoder.encode(bridgeToken),
73
+ { name: "HMAC", hash: "SHA-256" },
74
+ false,
75
+ ["sign"]
76
+ );
77
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(_CONTENT_WAKE_LABEL));
78
+ const wakeToken = Array.from(new Uint8Array(signature), (byte) =>
79
+ byte.toString(16).padStart(2, "0")
80
+ ).join("");
81
+ if (!/^[0-9a-f]{64}$/.test(wakeToken)) throw new Error("invalid content wake token");
82
+ await browser.storage.local.set({ [_CONTENT_WAKE_TOKEN_STORAGE_KEY]: wakeToken });
83
+ } catch (error) {
84
+ await browser.storage.local.remove(_CONTENT_WAKE_TOKEN_STORAGE_KEY).catch(() => {});
85
+ throw error;
86
+ }
87
+ }
64
88
  let _targetProfile = null; // Profile name from server (e.g. "Automations")
65
89
  let _profileWindowId = null; // Discovered windowId for the profile (shared default)
66
90
  // Per-session profile window. One Safari profile can hold SEVERAL windows once
@@ -419,6 +443,18 @@ let _startupReady = browser.storage.local.get("mcpEnabled").then(data => {
419
443
 
420
444
  // Listen for messages from popup
421
445
  browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
446
+ // Safari can suspend an MV3 worker even while a content-script Port exists. A
447
+ // leader page therefore sends this fixed, authority-free message as a second
448
+ // keepalive signal. runtime.sendMessage is an extension event, so it wakes the
449
+ // worker on Safari versions where Port traffic alone does not. Never accept tab,
450
+ // URL, receipt, session, or command data through this path.
451
+ if (msg?.action === "mcpContentKeepalivePingV1") {
452
+ if (Object.keys(msg).length !== 1) return false;
453
+ if (!Number.isInteger(sender?.tab?.id)) return false;
454
+ if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return false;
455
+ sendResponse({ ok: true });
456
+ return false;
457
+ }
422
458
  if (msg.action === "setEnabled") {
423
459
  _enabled = msg.enabled;
424
460
  if (!_enabled) {
@@ -545,6 +581,14 @@ async function connect() {
545
581
  signal: AbortSignal.timeout(1500),
546
582
  });
547
583
  if (_restartInvalidatedConnect(pollGeneration)) return;
584
+ if (res.status === 423) {
585
+ isConnected = false;
586
+ updateBadge("");
587
+ _stopHeartbeat();
588
+ scheduleReconnect(3000);
589
+ _connecting = false;
590
+ return;
591
+ }
548
592
  if (!res.ok) continue;
549
593
 
550
594
  const data = await res.json().catch(() => ({}));
@@ -569,7 +613,7 @@ async function connect() {
569
613
  isConnected = false;
570
614
  updateBadge("");
571
615
  _stopHeartbeat();
572
- scheduleReconnect();
616
+ scheduleReconnect(3000);
573
617
  _connecting = false;
574
618
  return;
575
619
  }
@@ -604,17 +648,22 @@ async function connect() {
604
648
  _connecting = false;
605
649
  }
606
650
 
607
- function scheduleReconnect() {
651
+ function scheduleReconnect(delayOverrideMs = null) {
608
652
  if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return;
609
653
  // Cancel any existing reconnect to prevent exponential growth
610
654
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
611
655
 
612
656
  // Single timer with exponential backoff (3s → 6s → 12s → ... → 60s max)
657
+ const delay = Number.isFinite(delayOverrideMs)
658
+ ? Math.max(0, Number(delayOverrideMs))
659
+ : _reconnectDelay;
613
660
  _reconnectTimer = setTimeout(() => {
614
661
  _reconnectTimer = null;
615
662
  connect();
616
- }, _reconnectDelay);
617
- _reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
663
+ }, delay);
664
+ if (!Number.isFinite(delayOverrideMs)) {
665
+ _reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
666
+ }
618
667
 
619
668
  // Alarm as backup — wakes terminated service worker (Safari minimum 1 minute)
620
669
  try {
@@ -1038,11 +1087,17 @@ async function handleCommand(type, payload) {
1038
1087
  );
1039
1088
  if (!r || r.ok !== true) throw new Error((r && r.error) || "content bridge failed");
1040
1089
  _injectionBlockedTabs.add(evalTabId);
1090
+ if (evalOrigin) _injectionBlockedOrigins.set(evalOrigin, Date.now() + _INJECTION_BLOCK_TTL_MS);
1041
1091
  return r.value;
1042
1092
  };
1043
- if (_injectionBlockedTabs.has(evalTabId)) {
1093
+ const evalOrigin = _originKey(targetTab?.url);
1094
+ const originBlocked = !!evalOrigin && (_injectionBlockedOrigins.get(evalOrigin) || 0) > Date.now();
1095
+ if (_injectionBlockedTabs.has(evalTabId) || originBlocked) {
1044
1096
  try { return await viaBridge(); }
1045
- catch (_e) { _injectionBlockedTabs.delete(evalTabId); } // page changed — re-probe
1097
+ catch (_e) { // page changed — re-probe
1098
+ _injectionBlockedTabs.delete(evalTabId);
1099
+ if (evalOrigin) _injectionBlockedOrigins.delete(evalOrigin);
1100
+ }
1046
1101
  }
1047
1102
 
1048
1103
  // First contact with a hardened page: every execInTab strategy below will stall.
@@ -1153,14 +1208,23 @@ async function handleCommand(type, payload) {
1153
1208
  // If it fails, fall back to AppleScript screencapture -l (which also doesn't steal focus).
1154
1209
  // NEVER use browser.windows.update({ focused: true }) — it steals user's keyboard/mouse.
1155
1210
  let captureWindowId = _profileWindowId || null;
1211
+ // captureVisibleTab only sees the window's selected tab, so ours must be selected for
1212
+ // the capture — but the selection belongs to the user when they browse this window.
1213
+ // Remember what they had selected and put it back (the AppleScript path already does
1214
+ // this via _withTargetTabFronted; this path used to leave our tab in front).
1215
+ let restoreTabId = null;
1156
1216
  if (tabId) {
1157
1217
  try {
1158
1218
  const tabInfo = await browser.tabs.get(tabId);
1159
1219
  captureWindowId = tabInfo.windowId;
1160
- // Only activate the correct tab — does NOT bring Safari window to foreground
1161
- await browser.tabs.update(tabId, { active: true });
1220
+ if (!tabInfo.active) {
1221
+ const [selected] = await browser.tabs.query({ active: true, windowId: tabInfo.windowId });
1222
+ if (selected && selected.id !== tabId) restoreTabId = selected.id;
1223
+ // Only selects the tab — does NOT bring the Safari window to the foreground
1224
+ await browser.tabs.update(tabId, { active: true });
1225
+ await new Promise(r => setTimeout(r, 150));
1226
+ }
1162
1227
  } catch (_) {}
1163
- await new Promise(r => setTimeout(r, 150));
1164
1228
  }
1165
1229
  // Use JPEG with quality 50 to reduce size (~600KB PNG → ~60KB JPEG)
1166
1230
  try {
@@ -1178,6 +1242,8 @@ async function handleCommand(type, payload) {
1178
1242
  }
1179
1243
  // Any other error also falls back — better than stealing focus
1180
1244
  return "__SCREENSHOT_PERMISSION_DENIED__";
1245
+ } finally {
1246
+ if (restoreTabId !== null) browser.tabs.update(restoreTabId, { active: true }).catch(() => {});
1181
1247
  }
1182
1248
  }
1183
1249
 
@@ -3668,6 +3734,15 @@ const MAIN_WORLD_INJECT_MS = 3000;
3668
3734
  // pages fast instead of merely working. Cleared when the tab navigates or closes, so a
3669
3735
  // page that stops blocking injection is re-probed rather than downgraded forever.
3670
3736
  const _injectionBlockedTabs = new Set();
3737
+ // Same fact, keyed by origin: the per-tab set is cleared on every navigation, so a hardened
3738
+ // SPA (business.facebook.com) re-paid the ~10s probe on the first evaluate after EACH
3739
+ // navigate — 356 evaluates ≥10s in one week. An origin that blocked injection once is sent
3740
+ // straight to the bridge for a few hours; a bridge failure clears it and re-probes.
3741
+ const _injectionBlockedOrigins = new Map(); // origin → expiry (ms)
3742
+ const _INJECTION_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
3743
+ function _originKey(url) {
3744
+ try { return new URL(String(url || "")).origin; } catch { return ""; }
3745
+ }
3671
3746
  function _withInjectionDeadline(promise, ms = MAIN_WORLD_INJECT_MS) {
3672
3747
  let timer;
3673
3748
  return Promise.race([
@@ -4308,6 +4383,7 @@ console.log("Safari MCP Bridge: service worker started");
4308
4383
  updateBadge("");
4309
4384
  // Wait for storage to load before connecting (prevents race condition with _enabled)
4310
4385
  _startupReady.then(() => {
4386
+ _publishContentWakeToken().catch(() => {});
4311
4387
  if (_enabled) connect();
4312
4388
  else updateBadge("OFF");
4313
4389
  }).catch(() => connect());
@@ -32,6 +32,10 @@ const _MCP_KEEPALIVE_LEASE_MS = 26000;
32
32
  const _MCP_KEEPALIVE_RENEW_MS = 18000;
33
33
  const _MCP_KEEPALIVE_PING_MS = 8000;
34
34
  const _MCP_KEEPALIVE_RECONNECT_MS = 1500;
35
+ const _MCP_CONTENT_WAKE_TOKEN_KEY = "mcpContentWakeTokenV1";
36
+ const _MCP_CONTENT_WAKE_BRIDGE_KEY = "mcpBridgeUrl";
37
+ const _MCP_CONTENT_WAKE_RETRY_MS = 1500;
38
+ const _MCP_CONTENT_WAKE_PORTS = new Set(["9224", "9228", "9232", "9236"]);
35
39
  const _mcpKeepaliveInstanceId = inheritedKeepaliveId || (() => {
36
40
  const bytes = new Uint8Array(12);
37
41
  crypto.getRandomValues(bytes);
@@ -47,6 +51,11 @@ let _mcpKeepaliveEnabledRevision = 0;
47
51
  let _mcpKeepaliveLeader = false;
48
52
  let _mcpKeepaliveClaiming = false;
49
53
  let _mcpKeepaliveDisposed = false;
54
+ let _mcpContentWakeToken = "";
55
+ let _mcpContentWakeBridgeUrl = "";
56
+ let _mcpContentWakeAbort = null;
57
+ let _mcpContentWakeRetryTimer = null;
58
+ let _mcpContentWakeGeneration = 0;
50
59
 
51
60
  function _isMcpKeepaliveTopFrame() {
52
61
  try { return window.top === window; } catch (_) { return false; }
@@ -134,9 +143,110 @@ function _clearMcpKeepalivePingTimer() {
134
143
  _mcpKeepalivePingTimer = null;
135
144
  }
136
145
 
146
+ function _validMcpContentWakeToken(value) {
147
+ return /^[0-9a-f]{64}$/.test(String(value || "")) ? String(value) : "";
148
+ }
149
+
150
+ function _validMcpContentWakeBridgeUrl(value) {
151
+ try {
152
+ const url = new URL(String(value || ""));
153
+ if (url.protocol !== "http:") return "";
154
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return "";
155
+ if (!_MCP_CONTENT_WAKE_PORTS.has(url.port)) return "";
156
+ if (url.pathname !== "/" || url.search || url.hash) return "";
157
+ return url.origin;
158
+ } catch (_) {
159
+ return "";
160
+ }
161
+ }
162
+
163
+ function _sendMcpRuntimeWake() {
164
+ try {
165
+ const wakeup = browser.runtime.sendMessage({ action: "mcpContentKeepalivePingV1" });
166
+ wakeup?.catch?.(() => {});
167
+ } catch (_) {}
168
+ }
169
+
170
+ function _stopMcpContentWakeLoop() {
171
+ _mcpContentWakeGeneration += 1;
172
+ if (_mcpContentWakeRetryTimer) clearTimeout(_mcpContentWakeRetryTimer);
173
+ _mcpContentWakeRetryTimer = null;
174
+ const controller = _mcpContentWakeAbort;
175
+ _mcpContentWakeAbort = null;
176
+ if (controller) {
177
+ try { controller.abort(); } catch (_) {}
178
+ }
179
+ }
180
+
181
+ function _startMcpContentWakeLoop() {
182
+ if (
183
+ _mcpKeepaliveDisposed ||
184
+ !_mcpKeepaliveLeader ||
185
+ !_mcpKeepaliveEnabled ||
186
+ !_mcpContentWakeToken ||
187
+ !_mcpContentWakeBridgeUrl ||
188
+ _mcpContentWakeAbort ||
189
+ _mcpContentWakeRetryTimer
190
+ )
191
+ return;
192
+ const generation = ++_mcpContentWakeGeneration;
193
+ void _runMcpContentWakePoll(generation);
194
+ }
195
+
196
+ async function _runMcpContentWakePoll(generation) {
197
+ if (
198
+ generation !== _mcpContentWakeGeneration ||
199
+ _mcpKeepaliveDisposed ||
200
+ !_mcpKeepaliveLeader ||
201
+ !_mcpKeepaliveEnabled ||
202
+ !_mcpContentWakeToken ||
203
+ !_mcpContentWakeBridgeUrl
204
+ )
205
+ return;
206
+ const controller = new AbortController();
207
+ _mcpContentWakeAbort = controller;
208
+ let succeeded = false;
209
+ try {
210
+ const response = await fetch(`${_mcpContentWakeBridgeUrl}/content-wakeup`, {
211
+ cache: "no-store",
212
+ credentials: "omit",
213
+ headers: { "X-Safari-MCP-Wakeup": _mcpContentWakeToken },
214
+ mode: "cors",
215
+ referrerPolicy: "no-referrer",
216
+ signal: controller.signal,
217
+ });
218
+ succeeded = response.status === 204;
219
+ } catch (_) {
220
+ succeeded = false;
221
+ } finally {
222
+ if (_mcpContentWakeAbort === controller) _mcpContentWakeAbort = null;
223
+ }
224
+ if (
225
+ generation !== _mcpContentWakeGeneration ||
226
+ _mcpKeepaliveDisposed ||
227
+ !_mcpKeepaliveLeader ||
228
+ !_mcpKeepaliveEnabled
229
+ )
230
+ return;
231
+ if (succeeded) {
232
+ _sendMcpRuntimeWake();
233
+ void _runMcpContentWakePoll(generation);
234
+ return;
235
+ }
236
+ _mcpContentWakeRetryTimer = setTimeout(() => {
237
+ _mcpContentWakeRetryTimer = null;
238
+ if (generation !== _mcpContentWakeGeneration) return;
239
+ void _runMcpContentWakePoll(generation);
240
+ }, _MCP_CONTENT_WAKE_RETRY_MS);
241
+ }
242
+
137
243
  function _sendMcpKeepalivePing() {
138
244
  if (_mcpKeepaliveDisposed || !_mcpKeepaliveLeader || !_mcpKeepaliveEnabled) return;
139
245
  _clearMcpKeepalivePingTimer();
246
+ // A direct runtime message wakes Safari's MV3 worker. The Port remains useful as
247
+ // a low-overhead continuous lease, but real Safari can suspend the worker despite
248
+ // an open Port; using both paths avoids the observed 30-second poll gaps.
249
+ _sendMcpRuntimeWake();
140
250
  const port = _openMcpKeepalivePort();
141
251
  if (port) {
142
252
  try { port.postMessage({ type: "ping" }); }
@@ -155,6 +265,7 @@ function _stopMcpKeepaliveLeadership() {
155
265
  if (_mcpKeepaliveReconnectTimer) clearTimeout(_mcpKeepaliveReconnectTimer);
156
266
  _mcpKeepaliveReconnectTimer = null;
157
267
  _clearMcpKeepalivePingTimer();
268
+ _stopMcpContentWakeLoop();
158
269
  _closeMcpKeepalivePort();
159
270
  }
160
271
 
@@ -183,6 +294,7 @@ function _becomeMcpKeepaliveLeader() {
183
294
  if (_mcpKeepaliveDisposed || !_mcpKeepaliveEnabled || !_isMcpKeepaliveTopFrame()) return;
184
295
  _mcpKeepaliveLeader = true;
185
296
  _clearMcpKeepaliveLeaseTimer();
297
+ _startMcpContentWakeLoop();
186
298
  _renewMcpKeepaliveLeaseAndPing();
187
299
  }
188
300
 
@@ -270,9 +382,20 @@ async function _initializeMcpKeepalive() {
270
382
  document.addEventListener("visibilitychange", _onMcpKeepaliveVisibilityChange);
271
383
  browser.storage.onChanged.addListener(_onMcpKeepaliveStorageChanged);
272
384
  const enabledRevision = _mcpKeepaliveEnabledRevision;
273
- const stored = await browser.storage.local.get(["mcpEnabled", _MCP_KEEPALIVE_LEASE_KEY]).catch(() => ({}));
385
+ const stored = await browser.storage.local
386
+ .get([
387
+ "mcpEnabled",
388
+ _MCP_KEEPALIVE_LEASE_KEY,
389
+ _MCP_CONTENT_WAKE_TOKEN_KEY,
390
+ _MCP_CONTENT_WAKE_BRIDGE_KEY,
391
+ ])
392
+ .catch(() => ({}));
274
393
  if (_mcpKeepaliveDisposed || enabledRevision !== _mcpKeepaliveEnabledRevision) return;
275
394
  _mcpKeepaliveEnabled = stored.mcpEnabled !== false;
395
+ _mcpContentWakeToken = _validMcpContentWakeToken(stored[_MCP_CONTENT_WAKE_TOKEN_KEY]);
396
+ _mcpContentWakeBridgeUrl = _validMcpContentWakeBridgeUrl(
397
+ stored[_MCP_CONTENT_WAKE_BRIDGE_KEY]
398
+ );
276
399
  if (_mcpKeepaliveEnabled) {
277
400
  _considerMcpKeepaliveLeadership(stored[_MCP_KEEPALIVE_LEASE_KEY]);
278
401
  }
@@ -280,6 +403,20 @@ async function _initializeMcpKeepalive() {
280
403
 
281
404
  async function _onMcpKeepaliveStorageChanged(changes, areaName) {
282
405
  if (_mcpKeepaliveDisposed || areaName !== "local") return;
406
+ if (changes[_MCP_CONTENT_WAKE_TOKEN_KEY] || changes[_MCP_CONTENT_WAKE_BRIDGE_KEY]) {
407
+ _stopMcpContentWakeLoop();
408
+ if (changes[_MCP_CONTENT_WAKE_TOKEN_KEY]) {
409
+ _mcpContentWakeToken = _validMcpContentWakeToken(
410
+ changes[_MCP_CONTENT_WAKE_TOKEN_KEY].newValue
411
+ );
412
+ }
413
+ if (changes[_MCP_CONTENT_WAKE_BRIDGE_KEY]) {
414
+ _mcpContentWakeBridgeUrl = _validMcpContentWakeBridgeUrl(
415
+ changes[_MCP_CONTENT_WAKE_BRIDGE_KEY].newValue
416
+ );
417
+ }
418
+ if (_mcpKeepaliveLeader) _startMcpContentWakeLoop();
419
+ }
283
420
  if (changes.mcpEnabled) {
284
421
  _mcpKeepaliveEnabledRevision += 1;
285
422
  _mcpKeepaliveEnabled = changes.mcpEnabled.newValue !== false;
@@ -2,25 +2,43 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.10.4",
5
+ "version": "2.10.8",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
9
9
  "128": "images/icon-128.png"
10
10
  },
11
- "background": { "service_worker": "background.js" },
11
+ "background": {
12
+ "scripts": [
13
+ "background.js"
14
+ ],
15
+ "persistent": false
16
+ },
12
17
  "action": {
13
18
  "default_popup": "popup/popup.html",
14
- "default_icon": { "16": "images/icon-48.png", "32": "images/icon-48.png" }
19
+ "default_icon": {
20
+ "16": "images/icon-48.png",
21
+ "32": "images/icon-48.png"
22
+ }
15
23
  },
16
24
  "content_security_policy": {
17
25
  "extension_pages": "script-src 'self'; connect-src 'self' http://localhost:9224 http://127.0.0.1:9224 http://localhost:9228 http://127.0.0.1:9228 http://localhost:9232 http://127.0.0.1:9232 http://localhost:9236 http://127.0.0.1:9236"
18
26
  },
19
- "permissions": ["activeTab", "scripting", "tabs", "alarms", "storage"],
20
- "host_permissions": ["<all_urls>"],
27
+ "permissions": [
28
+ "activeTab",
29
+ "scripting",
30
+ "tabs",
31
+ "alarms",
32
+ "storage"
33
+ ],
34
+ "host_permissions": [
35
+ "<all_urls>"
36
+ ],
21
37
  "content_scripts": [
22
38
  {
23
- "matches": ["<all_urls>"],
39
+ "matches": [
40
+ "<all_urls>"
41
+ ],
24
42
  "exclude_matches": [
25
43
  "*://*.linkedin.com/*",
26
44
  "*://*.x.com/*",
@@ -31,13 +49,19 @@
31
49
  "*://chat.google.com/*",
32
50
  "*://meet.google.com/*"
33
51
  ],
34
- "js": ["content.js"],
52
+ "js": [
53
+ "content.js"
54
+ ],
35
55
  "run_at": "document_start",
36
56
  "world": "MAIN"
37
57
  },
38
58
  {
39
- "matches": ["<all_urls>"],
40
- "js": ["command-content.js"],
59
+ "matches": [
60
+ "<all_urls>"
61
+ ],
62
+ "js": [
63
+ "command-content.js"
64
+ ],
41
65
  "run_at": "document_start"
42
66
  }
43
67
  ]
package/image-size.js ADDED
@@ -0,0 +1,17 @@
1
+ // Pixel size of a JPEG straight from its SOF marker — enough to decide whether a screenshot
2
+ // needs downscaling without decoding it or pulling in an image dependency.
3
+ export function jpegSize(buf) {
4
+ if (!buf || buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
5
+ let i = 2;
6
+ while (i + 9 < buf.length) {
7
+ if (buf[i] !== 0xff) { i++; continue; }
8
+ const marker = buf[i + 1];
9
+ if (marker === 0xff) { i++; continue; } // fill byte
10
+ if (marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; } // standalone markers
11
+ if (marker === 0xda || marker === 0xd9) return null; // scan data / EOI before any SOF
12
+ const isSOF = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
13
+ if (isSOF) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
14
+ i += 2 + buf.readUInt16BE(i + 2);
15
+ }
16
+ return null;
17
+ }