@velanir/openclaw-browserbase 0.1.1 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +13 -8
  2. package/dist/index.js +70 -163
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,14 +27,19 @@ browser tool ──cdpUrl http://127.0.0.1:<port>/?token=…──> broker (this
27
27
  - Every session is created with the coworker's persistent context
28
28
  (`persist: true`), the configured timeout/viewport/captcha/recording policy,
29
29
  and `userMetadata` tagging for observability and reaping.
30
- - On downstream disconnect the broker **lingers**: the session and its
31
- upstream connection are held (default 60s) so the next browser action
32
- re-attaches instantly no new mint, no cooldown. OpenClaw's browser tool
33
- opens short probe connections and reconnects between actions; without
34
- linger every probe minted (and released) a billable session and the
35
- post-release cooldown starved the real connection. Explicit release, idle
36
- disconnect, linger expiry, and gateway shutdown all still release
37
- deterministically; login state survives in the context either way.
30
+ - Sessions are created with **`keepAlive: true`**, so on downstream
31
+ disconnect the broker **lingers**: it closes the CDP connection but keeps
32
+ the Browserbase session alive (default 60s) and **re-dials a fresh CDP
33
+ connection** to it on the next browser action — no new mint, no cooldown,
34
+ and tabs/login state intact. OpenClaw's browser tool opens short probe
35
+ connections and reconnects between actions; without linger every probe
36
+ minted (and released) a billable session and the post-release cooldown
37
+ starved the real connection. Because each client gets a brand-new CDP
38
+ connection (fresh id space), a late reply to a previous client's command
39
+ can never reach the next one. Explicit release, idle disconnect, linger
40
+ expiry, and gateway shutdown all release the keepAlive session
41
+ deterministically (keepAlive sessions do not self-terminate, so the reaper
42
+ also backstops crash leftovers).
38
43
  - One session per coworker context at a time (Browserbase forbids concurrent
39
44
  sessions on one context), with a short cooldown after release.
40
45
 
package/dist/index.js CHANGED
@@ -25,7 +25,13 @@ function buildSessionPayload(config, mint) {
25
25
  projectId: config.projectId,
26
26
  region: config.region,
27
27
  timeout: Math.round(config.defaults.timeoutSeconds),
28
- keepAlive: false,
28
+ // keepAlive lets the session survive a CDP disconnect so the broker can
29
+ // close the old connection and re-dial a FRESH one for the next client
30
+ // (linger) — no mint, and a fresh CDP id space so stale replies are
31
+ // impossible by construction. The broker is responsible for explicit
32
+ // release (linger expiry, idle, shutdown, reaper), since keepAlive
33
+ // sessions do not self-terminate on disconnect.
34
+ keepAlive: true,
29
35
  // Omit `proxies` entirely when unset so Browserbase applies plain direct
30
36
  // egress rather than an empty routing array.
31
37
  ...config.proxies.length > 0 ? { proxies: config.proxies } : {},
@@ -121,18 +127,8 @@ var BrowserbaseClient = class {
121
127
 
122
128
  // src/cdp-pipe.ts
123
129
  var INJECTED_ID_BASE = 19e8;
124
- var INJECTED_ID_PIPE_STRIDE = 1e6;
125
- var INJECTED_ID_PIPE_SLOTS = 200;
126
130
  var INJECTED_RESPONSE_MAX_BYTES = 4096;
127
131
  var DEFAULT_INJECTION_TIMEOUT_MS = 2e3;
128
- var STALE_REPLY_TTL_MS = 15e3;
129
- var MAX_TRACKED_CLIENT_IDS = 256;
130
- var FRAME_HEAD_ID_RE = /"id"\s*:\s*(\d+)/;
131
- function frameHeadId(data) {
132
- const head = Buffer.isBuffer(data) ? data.subarray(0, 64).toString("utf8") : Array.isArray(data) ? Buffer.concat(data).subarray(0, 64).toString("utf8") : Buffer.from(data).subarray(0, 64).toString("utf8");
133
- const match = FRAME_HEAD_ID_RE.exec(head);
134
- return match ? Number(match[1]) : null;
135
- }
136
132
  function rawDataByteLength(data) {
137
133
  if (Buffer.isBuffer(data)) {
138
134
  return data.byteLength;
@@ -151,90 +147,38 @@ function rawDataToString(data) {
151
147
  }
152
148
  return Buffer.from(data).toString("utf8");
153
149
  }
154
- var CdpPipe = class _CdpPipe {
150
+ var CdpPipe = class {
155
151
  constructor(downstream, upstream, opts) {
156
152
  this.downstream = downstream;
157
153
  this.upstream = upstream;
158
154
  this.opts = opts;
159
- const ttl = Date.now() + STALE_REPLY_TTL_MS;
160
- for (const id of opts.suppressStaleIds ?? []) {
161
- this.suppressStale.set(id, ttl);
162
- }
163
155
  downstream.on("message", (data, isBinary) => {
164
156
  this.lastActivityAt = Date.now();
165
- if (!isBinary && this.clientPendingIds.size < MAX_TRACKED_CLIENT_IDS) {
166
- const id = frameHeadId(data);
167
- if (id !== null && id < INJECTED_ID_BASE) {
168
- this.clientPendingIds.add(id);
169
- }
170
- }
171
157
  if (upstream.readyState === upstream.OPEN) {
172
158
  upstream.send(data, { binary: isBinary });
173
159
  }
174
160
  });
175
- upstream.on("message", this.onUpstreamMessage);
161
+ upstream.on("message", (data, isBinary) => {
162
+ this.lastActivityAt = Date.now();
163
+ if (!isBinary && this.maybeResolveInjected(data)) {
164
+ return;
165
+ }
166
+ if (downstream.readyState === downstream.OPEN) {
167
+ downstream.send(data, { binary: isBinary });
168
+ }
169
+ });
176
170
  downstream.on("close", () => this.close("downstream-closed"));
177
171
  downstream.on("error", () => this.close("downstream-error"));
178
- upstream.on("close", this.onUpstreamClose);
179
- upstream.on("error", this.onUpstreamError);
172
+ upstream.on("close", () => this.close("upstream-closed"));
173
+ upstream.on("error", () => this.close("upstream-error"));
180
174
  }
181
- static pipeSeq = 0;
182
- nextInjectedId = INJECTED_ID_BASE + _CdpPipe.pipeSeq++ % INJECTED_ID_PIPE_SLOTS * INJECTED_ID_PIPE_STRIDE;
175
+ nextInjectedId = INJECTED_ID_BASE;
183
176
  pending = /* @__PURE__ */ new Map();
184
177
  closeReason = null;
185
- upstreamClaimed = false;
186
- /** Downstream command ids sent upstream with no reply seen yet. */
187
- clientPendingIds = /* @__PURE__ */ new Set();
188
- /** Stale ids from the previous client on this upstream → suppression deadline. */
189
- suppressStale = /* @__PURE__ */ new Map();
190
178
  lastActivityAt = Date.now();
191
- // Named so takeUpstream() can detach exactly the pipe's listeners from a
192
- // claimed (lingering) upstream without disturbing the hold's own listeners.
193
- onUpstreamMessage = (data, isBinary) => {
194
- this.lastActivityAt = Date.now();
195
- if (!isBinary) {
196
- const id = frameHeadId(data);
197
- if (id !== null) {
198
- this.clientPendingIds.delete(id);
199
- const deadline = this.suppressStale.get(id);
200
- if (deadline !== void 0) {
201
- this.suppressStale.delete(id);
202
- if (Date.now() <= deadline) {
203
- return;
204
- }
205
- }
206
- }
207
- if (this.maybeResolveInjected(data)) {
208
- return;
209
- }
210
- }
211
- if (this.downstream.readyState === this.downstream.OPEN) {
212
- this.downstream.send(data, { binary: isBinary });
213
- }
214
- };
215
- onUpstreamClose = () => this.close("upstream-closed");
216
- onUpstreamError = () => this.close("upstream-error");
217
179
  get closed() {
218
180
  return this.closeReason !== null;
219
181
  }
220
- /**
221
- * Claim the upstream socket out of the pipe while it is still OPEN —
222
- * callable from inside the onClose handler so the broker can keep the
223
- * Browserbase session alive (linger) instead of releasing on downstream
224
- * disconnect. Returns null when the upstream is already gone or claimed.
225
- * staleClientIds are the old client's still-unanswered command ids; the
226
- * next pipe on this upstream must suppress their late replies.
227
- */
228
- takeUpstream() {
229
- if (this.upstreamClaimed || this.upstream.readyState !== this.upstream.OPEN) {
230
- return null;
231
- }
232
- this.upstreamClaimed = true;
233
- this.upstream.off("message", this.onUpstreamMessage);
234
- this.upstream.off("close", this.onUpstreamClose);
235
- this.upstream.off("error", this.onUpstreamError);
236
- return { socket: this.upstream, staleClientIds: [...this.clientPendingIds] };
237
- }
238
182
  /** Send a broker-owned CDP command on the piped connection and await its reply. */
239
183
  inject(method, params) {
240
184
  if (this.closeReason !== null || this.upstream.readyState !== this.upstream.OPEN) {
@@ -290,9 +234,7 @@ var CdpPipe = class _CdpPipe {
290
234
  entry.reject(new Error(`CDP pipe closed: ${reason}`));
291
235
  }
292
236
  this.pending.clear();
293
- this.opts.onClose(reason);
294
- const sockets = this.upstreamClaimed ? [this.downstream] : [this.downstream, this.upstream];
295
- for (const socket of sockets) {
237
+ for (const socket of [this.downstream, this.upstream]) {
296
238
  if (socket.readyState === socket.OPEN || socket.readyState === socket.CLOSING) {
297
239
  try {
298
240
  socket.close(1e3);
@@ -303,6 +245,7 @@ var CdpPipe = class _CdpPipe {
303
245
  socket.terminate();
304
246
  }
305
247
  }
248
+ this.opts.onClose(reason);
306
249
  }
307
250
  };
308
251
 
@@ -587,6 +530,9 @@ var BrowserbaseBroker = class {
587
530
  idleSweepIntervalMs;
588
531
  server = null;
589
532
  active = null;
533
+ // A live keepAlive session held resumable (by connectUrl) for a window —
534
+ // re-dialed on the next connect (linger) or retry (adoption), released on
535
+ // expiry.
590
536
  adoptable = null;
591
537
  cooldownUntil = 0;
592
538
  timers = [];
@@ -646,8 +592,7 @@ var BrowserbaseBroker = class {
646
592
  clearTimeout(this.adoptable.timer);
647
593
  const held = this.adoptable;
648
594
  this.adoptable = null;
649
- held.upstream?.terminate();
650
- await this.releaseQuietly(held.session.sessionId, "shutdown-unattached");
595
+ await this.releaseQuietly(held.session.sessionId, "shutdown-resumable");
651
596
  }
652
597
  const active = this.active;
653
598
  if (active) {
@@ -799,9 +744,9 @@ var BrowserbaseBroker = class {
799
744
  socket.destroy();
800
745
  return;
801
746
  }
802
- let held;
747
+ let minted;
803
748
  try {
804
- held = await this.acquireSession(leaseId);
749
+ minted = await this.acquireSession(leaseId);
805
750
  } catch (err) {
806
751
  const message = err instanceof Error ? err.message : String(err);
807
752
  this.logger.warn?.(`session acquire failed: ${message}`);
@@ -813,45 +758,41 @@ var BrowserbaseBroker = class {
813
758
  }
814
759
  return;
815
760
  }
816
- const minted = held.session;
817
761
  if (liveness.gone) {
818
762
  liveness.detach();
819
763
  socket.destroy();
820
- this.holdForAdoption(minted, held.upstream, { staleClientIds: held.staleClientIds ?? [] });
764
+ this.holdForAdoption(minted);
821
765
  return;
822
766
  }
823
- let upstream = held.upstream && held.upstream.readyState === held.upstream.OPEN ? held.upstream : null;
824
- if (upstream === null) {
825
- held.upstream?.terminate();
826
- try {
827
- upstream = await this.dialUpstream(minted.connectUrl);
828
- } catch (err) {
829
- const message = err instanceof Error ? err.message : String(err);
830
- this.logger.warn?.(`upstream dial failed for session ${minted.sessionId}: ${message}`);
831
- await this.releaseQuietly(minted.sessionId, "upstream-dial-failed");
832
- this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
833
- liveness.detach();
834
- if (liveness.gone) {
835
- socket.destroy();
836
- } else {
837
- destroyWithHttpError(socket, 502, "Bad Gateway", `Browserbase connect failed: ${message}`);
838
- }
839
- return;
767
+ let upstream;
768
+ try {
769
+ upstream = await this.dialUpstream(minted.connectUrl);
770
+ } catch (err) {
771
+ const message = err instanceof Error ? err.message : String(err);
772
+ this.logger.warn?.(`upstream dial failed for session ${minted.sessionId}: ${message}`);
773
+ await this.releaseQuietly(minted.sessionId, "upstream-dial-failed");
774
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
775
+ liveness.detach();
776
+ if (liveness.gone) {
777
+ socket.destroy();
778
+ } else {
779
+ destroyWithHttpError(socket, 502, "Bad Gateway", `Browserbase connect failed: ${message}`);
840
780
  }
781
+ return;
841
782
  }
842
783
  if (liveness.gone) {
843
784
  liveness.detach();
844
785
  socket.destroy();
845
- this.holdForAdoption(minted, upstream, { staleClientIds: held.staleClientIds ?? [] });
786
+ upstream.terminate();
787
+ this.holdForAdoption(minted);
846
788
  return;
847
789
  }
848
790
  liveness.detach();
849
- const staleClientIds = held.staleClientIds ?? [];
850
791
  this.wss.handleUpgrade(req, socket, head, (downstream) => {
851
- this.attachPipe(downstream, upstream, minted, staleClientIds);
792
+ this.attachPipe(downstream, upstream, minted);
852
793
  });
853
794
  }
854
- attachPipe(downstream, upstream, minted, staleClientIds = []) {
795
+ attachPipe(downstream, upstream, minted) {
855
796
  if (upstream.readyState !== upstream.OPEN || downstream.readyState !== downstream.OPEN) {
856
797
  upstream.terminate();
857
798
  if (downstream.readyState === downstream.OPEN) {
@@ -864,8 +805,7 @@ var BrowserbaseBroker = class {
864
805
  return;
865
806
  }
866
807
  const pipe = new CdpPipe(downstream, upstream, {
867
- onClose: (reason) => this.onPipeClosed(minted.sessionId, reason),
868
- ...staleClientIds.length ? { suppressStaleIds: staleClientIds } : {}
808
+ onClose: (reason) => this.onPipeClosed(minted.sessionId, reason)
869
809
  });
870
810
  this.active = {
871
811
  sessionId: minted.sessionId,
@@ -888,21 +828,19 @@ var BrowserbaseBroker = class {
888
828
  this.active = null;
889
829
  const downstreamGone = reason === "downstream-closed" || reason === "downstream-error";
890
830
  if (downstreamGone && !this.stopping && this.config.lingerSeconds > 0) {
891
- const claimed = active.pipe.takeUpstream();
892
- if (claimed) {
893
- this.holdForAdoption(
894
- {
895
- sessionId: active.sessionId,
896
- connectUrl: active.connectUrl,
897
- contextId: active.contextId,
898
- leaseId: active.leaseId,
899
- ...active.expiresAt ? { expiresAt: active.expiresAt } : {}
900
- },
901
- claimed.socket,
902
- { windowMs: this.config.lingerSeconds * 1e3, reason: "linger", staleClientIds: claimed.staleClientIds }
903
- );
904
- return;
905
- }
831
+ this.holdForAdoption(
832
+ {
833
+ sessionId: active.sessionId,
834
+ connectUrl: active.connectUrl,
835
+ contextId: active.contextId,
836
+ leaseId: active.leaseId,
837
+ ...active.expiresAt ? { expiresAt: active.expiresAt } : {}
838
+ },
839
+ { windowMs: this.config.lingerSeconds * 1e3, reason: "linger" }
840
+ );
841
+ this.logger.info?.(`session ${sessionId} disconnected (${reason}); lingering for re-dial (keepAlive)`);
842
+ this.onActiveReleased?.();
843
+ return;
906
844
  }
907
845
  this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
908
846
  this.logger.info?.(`session ${sessionId} disconnected (${reason}); releasing`);
@@ -915,7 +853,7 @@ var BrowserbaseBroker = class {
915
853
  const adopted = this.takeAdoptable();
916
854
  if (adopted) {
917
855
  this.logger.info?.(
918
- `adopting held session ${adopted.session.sessionId} (minted for lease ${adopted.session.leaseId}, requested by lease ${leaseId})`
856
+ `resuming held session ${adopted.sessionId} (minted for lease ${adopted.leaseId}, requested by lease ${leaseId})`
919
857
  );
920
858
  return adopted;
921
859
  }
@@ -932,7 +870,7 @@ var BrowserbaseBroker = class {
932
870
  if (cooldownRemaining > 0) {
933
871
  await sleep(cooldownRemaining);
934
872
  }
935
- return { session: await this.mintSession(leaseId) };
873
+ return this.mintSession(leaseId);
936
874
  }
937
875
  async mintSession(leaseId) {
938
876
  const contextId = await this.ensureContext();
@@ -994,43 +932,21 @@ var BrowserbaseBroker = class {
994
932
  });
995
933
  });
996
934
  }
997
- holdForAdoption(session, upstream, opts = {}) {
935
+ holdForAdoption(session, opts = {}) {
998
936
  const reason = opts.reason ?? "adoption";
999
937
  const windowMs = opts.windowMs ?? ADOPTION_WINDOW_MS;
1000
938
  this.logger.info?.(
1001
- `holding session ${session.sessionId} (${reason}, ${Math.round(windowMs / 1e3)}s window, upstream ${upstream ? "open" : "not dialed"})`
939
+ `holding session ${session.sessionId} (${reason}, ${Math.round(windowMs / 1e3)}s window) for re-dial`
1002
940
  );
1003
941
  const timer = setTimeout(() => {
1004
942
  if (this.adoptable?.session.sessionId === session.sessionId) {
1005
- const held = this.adoptable;
1006
943
  this.adoptable = null;
1007
- held.upstream?.terminate();
1008
944
  this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
1009
945
  void this.releaseQuietly(session.sessionId, `${reason}-expired`);
1010
946
  }
1011
947
  }, windowMs);
1012
948
  timer.unref();
1013
- let onUpstreamLost;
1014
- if (upstream) {
1015
- onUpstreamLost = () => {
1016
- if (this.adoptable?.session.sessionId === session.sessionId && this.adoptable.upstream === upstream) {
1017
- clearTimeout(this.adoptable.timer);
1018
- this.adoptable = null;
1019
- this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
1020
- void this.releaseQuietly(session.sessionId, `${reason}-upstream-lost`);
1021
- }
1022
- };
1023
- upstream.once("close", onUpstreamLost);
1024
- upstream.once("error", onUpstreamLost);
1025
- }
1026
- this.adoptable = {
1027
- session,
1028
- heldReason: reason,
1029
- ...upstream ? { upstream } : {},
1030
- ...opts.staleClientIds?.length ? { staleClientIds: opts.staleClientIds } : {},
1031
- ...onUpstreamLost ? { onUpstreamLost } : {},
1032
- timer
1033
- };
949
+ this.adoptable = { session, heldReason: reason, timer };
1034
950
  }
1035
951
  takeAdoptable() {
1036
952
  const held = this.adoptable;
@@ -1038,16 +954,8 @@ var BrowserbaseBroker = class {
1038
954
  return null;
1039
955
  }
1040
956
  clearTimeout(held.timer);
1041
- if (held.upstream && held.onUpstreamLost) {
1042
- held.upstream.off("close", held.onUpstreamLost);
1043
- held.upstream.off("error", held.onUpstreamLost);
1044
- }
1045
957
  this.adoptable = null;
1046
- return {
1047
- session: held.session,
1048
- ...held.upstream ? { upstream: held.upstream } : {},
1049
- ...held.staleClientIds?.length ? { staleClientIds: held.staleClientIds } : {}
1050
- };
958
+ return held.session;
1051
959
  }
1052
960
  // ------------------------------------------------------------- cleanup --
1053
961
  async releaseQuietly(sessionId, reason) {
@@ -1118,7 +1026,7 @@ var BrowserbaseBroker = class {
1118
1026
  };
1119
1027
  }
1120
1028
  async getLiveViewUrls() {
1121
- const sessionId = this.active && !this.active.pipe.closed ? this.active.sessionId : this.adoptable?.upstream ? this.adoptable.session.sessionId : null;
1029
+ const sessionId = this.active && !this.active.pipe.closed ? this.active.sessionId : this.adoptable ? this.adoptable.session.sessionId : null;
1122
1030
  if (!sessionId) {
1123
1031
  return null;
1124
1032
  }
@@ -1133,9 +1041,8 @@ var BrowserbaseBroker = class {
1133
1041
  }
1134
1042
  const held = this.takeAdoptable();
1135
1043
  if (held) {
1136
- held.upstream?.terminate();
1137
1044
  this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
1138
- void this.releaseQuietly(held.session.sessionId, "manual-release");
1045
+ void this.releaseQuietly(held.sessionId, "manual-release");
1139
1046
  return true;
1140
1047
  }
1141
1048
  return false;
@@ -1165,7 +1072,7 @@ function createBrowserbaseTools(getBroker) {
1165
1072
  const status = broker.getStatus();
1166
1073
  if (!status.active) {
1167
1074
  const lines2 = [
1168
- status.held?.reason === "linger" ? `Browserbase session ${status.held.sessionId} is lingering for instant resume \u2014 the next browser action re-attaches to it (same tabs may be gone, login state intact).` : NO_SESSION,
1075
+ status.held?.reason === "linger" ? `Browserbase session ${status.held.sessionId} is paused (keepAlive) for resume \u2014 the next browser action reconnects to it with your tabs and login state intact.` : NO_SESSION,
1169
1076
  `Region: ${status.region}. Recording: ${status.recordSession ? "on" : "off"}.`,
1170
1077
  status.contextId ? `Browser context: ${status.contextId}` : "Browser context: created on first use."
1171
1078
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velanir/openclaw-browserbase",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw plugin that brokers Browserbase sessions behind a loopback CDP endpoint: explicit session creation with per-coworker persistent contexts, deterministic release, and stale-session reaping.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",