bunite-core 0.14.0 → 0.16.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.
@@ -3,9 +3,14 @@
3
3
  import type { ClientOf } from "../rpc/index";
4
4
  import type {
5
5
  SurfaceCap, EvaluateResult, SurfaceCapabilities, ScreenshotResult,
6
- SurfaceEvent, ConsoleEntry, WaitResult,
6
+ SurfaceEvent, ConsoleEntry, WaitResult, NavigationState,
7
+ AccessibilitySnapshotResult, BoundingRectResult, ListFramesResult,
8
+ DownloadEvent, DownloadPolicy, WaitForDownloadResult,
9
+ ResolveAndClickResult,
7
10
  } from "../rpc/framework";
8
11
 
12
+ declare const __buniteWebviewId: number;
13
+
9
14
  declare const host: {
10
15
  runtime(): Promise<ClientOf<typeof import("../rpc/framework").RuntimeCap>>;
11
16
  };
@@ -138,17 +143,24 @@ class BuniteWebviewElement extends HTMLElement {
138
143
  this._unsubNavigate = () => ctrl.abort();
139
144
  void (async () => {
140
145
  try {
141
- // Wait until *this* connection's init resolves, then capture surfaceId
142
- // atomically before subscribing. Re-entrant connects abort prior loops
143
- // via `ctrl`, so a stale resolve from a previous cycle can't leak in.
144
146
  const s = await getSurfaceCap();
145
147
  if (ctrl.signal.aborted) return;
146
148
  await this._waitForSurfaceId(ctrl.signal);
147
149
  const sid = this._surfaceId;
148
150
  if (ctrl.signal.aborted || sid == null) return;
149
- const stream = s.surfaceEvents({ surfaceId: sid });
150
- this._activeStreams.push(stream as { cancel?: () => void });
151
- for await (const event of stream) {
151
+ const surfStream = s.surfaceEvents({ surfaceId: sid });
152
+ this._activeStreams.push(surfStream as { cancel?: () => void });
153
+ const dlStream = s.downloadEvents({ surfaceId: sid });
154
+ this._activeStreams.push(dlStream as { cancel?: () => void });
155
+ (async () => {
156
+ try {
157
+ for await (const event of dlStream) {
158
+ if (ctrl.signal.aborted) break;
159
+ this.dispatchEvent(new CustomEvent<DownloadEvent>("download-event", { detail: event }));
160
+ }
161
+ } catch { /* stream torn down */ }
162
+ })();
163
+ for await (const event of surfStream) {
152
164
  if (ctrl.signal.aborted) break;
153
165
  this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: event }));
154
166
  }
@@ -264,10 +276,10 @@ class BuniteWebviewElement extends HTMLElement {
264
276
  this.setAttribute("src", url);
265
277
  }
266
278
 
267
- async evaluate(script: string): Promise<EvaluateResult> {
279
+ async evaluate(script: string, opts?: { frameId?: string }): Promise<EvaluateResult> {
268
280
  const sid = this._surfaceId;
269
281
  if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
270
- return callSurfaceTyped((s) => s.evaluate({ surfaceId: sid, script }));
282
+ return callSurfaceTyped((s) => s.evaluate({ surfaceId: sid, script, frameId: opts?.frameId }));
271
283
  }
272
284
 
273
285
  async capabilities(): Promise<SurfaceCapabilities> {
@@ -277,7 +289,8 @@ class BuniteWebviewElement extends HTMLElement {
277
289
  evaluate: false, crossOriginEval: false, surfaceEvents: false,
278
290
  nativeInputTrusted: false, click: false, type: false, press: false,
279
291
  scroll: false, mouse: false, dialogs: false, console: false,
280
- screenshot: false,
292
+ screenshot: false, accessibilitySnapshot: false, getBoundingRect: false,
293
+ frames: false, downloads: false, popups: false, resolveAndClick: false,
281
294
  };
282
295
  }
283
296
  return callSurfaceTyped((s) => s.capabilities({ surfaceId: sid }));
@@ -361,6 +374,64 @@ class BuniteWebviewElement extends HTMLElement {
361
374
  return (await callSurfaceTyped((s) => s.getConsoleBuffer({ surfaceId: sid, clear: opts?.clear }))) ?? [];
362
375
  }
363
376
 
377
+ async getNavigationState(): Promise<NavigationState> {
378
+ const sid = this._surfaceId;
379
+ if (sid == null) return { lastLoadEpoch: 0, isLoading: false, currentUrl: "" };
380
+ return callSurfaceTyped((s) => s.getNavigationState({ surfaceId: sid }));
381
+ }
382
+
383
+ async accessibilitySnapshot(opts?: { interestingOnly?: boolean }): Promise<AccessibilitySnapshotResult> {
384
+ const sid = this._surfaceId;
385
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
386
+ return callSurfaceTyped((s) => s.accessibilitySnapshot({ surfaceId: sid, interestingOnly: opts?.interestingOnly }));
387
+ }
388
+
389
+ async getBoundingRect(selector: string, opts?: { frameId?: string }): Promise<BoundingRectResult> {
390
+ const sid = this._surfaceId;
391
+ if (sid == null) return { ok: false, code: "runtime_error", message: "surface not ready" };
392
+ return callSurfaceTyped((s) => s.getBoundingRect({ surfaceId: sid, selector, frameId: opts?.frameId }));
393
+ }
394
+
395
+ async listFrames(): Promise<ListFramesResult> {
396
+ const sid = this._surfaceId;
397
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
398
+ return callSurfaceTyped((s) => s.listFrames({ surfaceId: sid }));
399
+ }
400
+
401
+ async resolveAndClick(
402
+ selector: string,
403
+ opts?: {
404
+ frameId?: string;
405
+ button?: "left" | "middle" | "right";
406
+ clickCount?: number;
407
+ modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
408
+ }
409
+ ): Promise<ResolveAndClickResult> {
410
+ const sid = this._surfaceId;
411
+ if (sid == null) return { ok: false, code: "runtime_error", message: "surface not ready" };
412
+ return callSurfaceTyped((s) => s.resolveAndClick({ surfaceId: sid, selector, ...opts }));
413
+ }
414
+
415
+ async setDownloadPolicy(policy: DownloadPolicy, downloadDir?: string): Promise<void> {
416
+ const sid = this._surfaceId;
417
+ if (sid == null) return;
418
+ await callSurface((s) => s.setDownloadPolicy({ surfaceId: sid, policy, downloadDir }));
419
+ }
420
+
421
+ async waitForDownload(opts?: { timeoutMs?: number }): Promise<WaitForDownloadResult> {
422
+ const sid = this._surfaceId;
423
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
424
+ return callSurfaceTyped((s) => s.waitForDownload({ surfaceId: sid, timeoutMs: opts?.timeoutMs }));
425
+ }
426
+
427
+ async dismissPopup(newSurfaceId: number): Promise<void> {
428
+ await callSurface((s) => s.dismissPopup({ newSurfaceId }));
429
+ }
430
+
431
+ async extendAdoptionTimeout(newSurfaceId: number, gracePeriodMs: number) {
432
+ return callSurfaceTyped((s) => s.extendPopupTimeout({ newSurfaceId, gracePeriodMs }));
433
+ }
434
+
364
435
  async screenshot(args?: { format?: "png" | "jpeg"; quality?: number }): Promise<ScreenshotResult> {
365
436
  const sid = this._surfaceId;
366
437
  if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
@@ -374,11 +445,71 @@ class BuniteWebviewElement extends HTMLElement {
374
445
  void callSurface((s) => s.setHidden({ surfaceId: sid, hidden }));
375
446
  }
376
447
 
448
+ private _setupSyncCtrl() {
449
+ this._syncCtrl = new OverlaySyncController(this, (rect) => {
450
+ const sid = this._surfaceId;
451
+ if (sid == null) return;
452
+ const isZero = rect.width === 0 && rect.height === 0;
453
+ if (isZero) {
454
+ if (!this._syncHidden) {
455
+ this._syncHidden = true;
456
+ this._applySurfaceHidden();
457
+ }
458
+ return;
459
+ }
460
+ if (this._syncHidden) {
461
+ this._syncHidden = false;
462
+ this._applySurfaceHidden();
463
+ }
464
+ void callSurface((s) => s.resize({
465
+ surfaceId: sid, x: rect.x, y: rect.y, w: rect.width, h: rect.height,
466
+ }));
467
+ });
468
+ this._syncCtrl.start();
469
+ }
470
+
377
471
  private initSurface() {
378
472
  if (this._surfaceId != null || this._initPromise != null) return;
379
473
 
380
474
  const dpr = window.devicePixelRatio || 1;
381
475
  const r = this.getBoundingClientRect();
476
+ const adoptAttr = this.getAttribute("adopt-popup-id");
477
+ const adoptId = adoptAttr ? Number(adoptAttr) : NaN;
478
+ if (Number.isFinite(adoptId)) {
479
+ // Popup adoption — bind a backend-minted surface (received via the parent
480
+ // page's `surface-event` `popup` arm) to this element.
481
+ const initPromise = getSurfaceCap().then(async (s) => {
482
+ const res = await s.acceptPopup({
483
+ newSurfaceId: adoptId,
484
+ hostViewId: __buniteWebviewId,
485
+ bounds: {
486
+ x: Math.round(r.x * dpr),
487
+ y: Math.round(r.y * dpr),
488
+ width: Math.round(r.width * dpr),
489
+ height: Math.round(r.height * dpr),
490
+ },
491
+ });
492
+ if (!res.ok) throw new Error(`acceptPopup failed: ${res.code}: ${res.message}`);
493
+ return { surfaceId: adoptId };
494
+ }) as Promise<SurfaceInitResponse>;
495
+ this._initPromise = initPromise;
496
+ initPromise.then((response) => {
497
+ if (this._initPromise !== initPromise) return;
498
+ if (this._aborted) {
499
+ void callSurface((s) => s.remove({ surfaceId: response.surfaceId }));
500
+ return;
501
+ }
502
+ this._surfaceId = response.surfaceId;
503
+ this._setupSyncCtrl();
504
+ }).catch((err) => {
505
+ if ((globalThis as { __BUNITE_DEBUG__?: boolean }).__BUNITE_DEBUG__) {
506
+ console.warn("[bunite] adopt-popup-id init failed", err);
507
+ }
508
+ this._initPromise = null;
509
+ });
510
+ return;
511
+ }
512
+
382
513
  const src = this._pendingSrc || this.getAttribute("src") || "";
383
514
  this._pendingSrc = null;
384
515
 
@@ -415,28 +546,7 @@ class BuniteWebviewElement extends HTMLElement {
415
546
  }
416
547
  }
417
548
 
418
- this._syncCtrl = new OverlaySyncController(this, (rect) => {
419
- const sid = this._surfaceId;
420
- if (sid == null) return;
421
-
422
- const isZero = rect.width === 0 && rect.height === 0;
423
- if (isZero) {
424
- if (!this._syncHidden) {
425
- this._syncHidden = true;
426
- this._applySurfaceHidden();
427
- }
428
- return;
429
- }
430
- if (this._syncHidden) {
431
- this._syncHidden = false;
432
- this._applySurfaceHidden();
433
- }
434
-
435
- void callSurface((s) => s.resize({
436
- surfaceId: sid, x: rect.x, y: rect.y, w: rect.width, h: rect.height,
437
- }));
438
- });
439
- this._syncCtrl.start();
549
+ this._setupSyncCtrl();
440
550
  })
441
551
  .catch(() => {})
442
552
  .finally(() => {
@@ -1,6 +1,6 @@
1
1
  // Iframe fallback for web (no-op if native already registered). HTMLElement deref'd lazily so module is import-safe in Node/Bun.
2
2
 
3
- import type { SurfaceEvent } from "../rpc/framework";
3
+ import type { SurfaceEvent, SurfaceEventBase } from "../rpc/framework";
4
4
 
5
5
  // Default sandbox omits allow-same-origin / allow-top-navigation / allow-modals /
6
6
  // allow-popups-to-escape-sandbox — popup escape stays opt-in so a sandboxed page
@@ -31,6 +31,12 @@ function definePolyfillClass(): CustomElementConstructor {
31
31
  private _iframe: HTMLIFrameElement | null = null;
32
32
  private _titleObserver: MutationObserver | null = null;
33
33
  private _lastTitle: string = "";
34
+ private _epoch: number = 0;
35
+ private _isLoading: boolean = false;
36
+ private _currentUrl: string = "";
37
+ // Local history stack for goBack — cross-origin contentWindow.history is
38
+ // inaccessible, so we track navigations ourselves.
39
+ private _history: string[] = [];
34
40
 
35
41
  private isReachable(): boolean {
36
42
  if (!this._iframe) return false;
@@ -50,8 +56,22 @@ function definePolyfillClass(): CustomElementConstructor {
50
56
  };
51
57
  }
52
58
 
53
- private emit(event: SurfaceEvent) {
54
- this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: event }));
59
+ private emit(event: SurfaceEventBase) {
60
+ if (event.type === "navigate") {
61
+ this._epoch++;
62
+ // Avoid pushing duplicate / same-as-top entries (reload doesn't grow history).
63
+ if (this._currentUrl && this._currentUrl !== event.url &&
64
+ this._history[this._history.length - 1] !== this._currentUrl) {
65
+ this._history.push(this._currentUrl);
66
+ }
67
+ this._currentUrl = event.url;
68
+ } else if (event.type === "load-start") {
69
+ this._isLoading = true;
70
+ } else if (event.type === "load-finish" || event.type === "load-fail") {
71
+ this._isLoading = false;
72
+ }
73
+ const stamped: SurfaceEvent = { ...event, epoch: this._epoch };
74
+ this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: stamped }));
55
75
  }
56
76
 
57
77
  private setupTitleObserver() {
@@ -100,6 +120,7 @@ function definePolyfillClass(): CustomElementConstructor {
100
120
  this.dispatchBlocked(src);
101
121
  } else {
102
122
  iframe.src = src;
123
+ this._currentUrl = src;
103
124
  this.emit({ type: "load-start", url: src });
104
125
  }
105
126
  }
@@ -112,8 +133,9 @@ function definePolyfillClass(): CustomElementConstructor {
112
133
  // Suppress the spurious about:blank load that fires after a blocked
113
134
  // navigation (or before any explicit navigate).
114
135
  if (isBlockedSrc(url)) return;
115
- this.emit({ type: "load-finish", url });
136
+ // navigate first so load-finish carries the bumped epoch.
116
137
  this.emit({ type: "navigate", url });
138
+ this.emit({ type: "load-finish", url });
117
139
  this.setupTitleObserver();
118
140
  });
119
141
 
@@ -148,19 +170,22 @@ function definePolyfillClass(): CustomElementConstructor {
148
170
  }
149
171
 
150
172
  goBack() {
151
- try {
152
- this._iframe?.contentWindow?.history.back();
153
- } catch {}
173
+ // Same-origin path uses native history. Cross-origin throws → fall back
174
+ // to our tracked stack (push on every navigate; pop here).
175
+ try { this._iframe?.contentWindow?.history.back(); return; } catch {}
176
+ const prev = this._history.pop();
177
+ if (prev && this._iframe) this._iframe.src = prev;
154
178
  }
155
179
 
156
180
  reload() {
157
- try {
158
- this._iframe?.contentWindow?.location.reload();
159
- } catch {
160
- if (this._iframe) {
161
- this._iframe.src = this._iframe.src;
162
- }
163
- }
181
+ try { this._iframe?.contentWindow?.location.reload(); return; } catch {}
182
+ // Cross-origin fallback: reassigning the same src is a no-op in WHATWG;
183
+ // cycle via about:blank to force a fresh navigation.
184
+ const iframe = this._iframe;
185
+ if (!iframe) return;
186
+ const url = this._currentUrl || iframe.src;
187
+ iframe.src = "about:blank";
188
+ requestAnimationFrame(() => { if (this._iframe) this._iframe.src = url; });
164
189
  }
165
190
 
166
191
  setHidden(hidden: boolean) {
@@ -219,6 +244,54 @@ function definePolyfillClass(): CustomElementConstructor {
219
244
  target.dispatchEvent(new MouseEvent("click", init));
220
245
  }
221
246
 
247
+ async resolveAndClick(_selector: string, _opts?: unknown) {
248
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill iframe: atomic resolveAndClick not supported" };
249
+ }
250
+
251
+ async getBoundingRect(selector: string, _opts?: unknown) {
252
+ if (!this.isReachable()) return { ok: false as const, code: "not_supported" as const, message: "iframe not reachable" };
253
+ try {
254
+ const el = this._iframe!.contentDocument!.querySelector(selector) as Element | null;
255
+ if (!el) return { ok: false as const, code: "not_found" as const, message: `selector ${selector} not found` };
256
+ const r = el.getBoundingClientRect();
257
+ const win = this._iframe!.contentWindow!;
258
+ const visible = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0
259
+ && r.top < win.innerHeight && r.left < win.innerWidth;
260
+ return { ok: true as const, rect: { x: r.x, y: r.y, width: r.width, height: r.height }, visible };
261
+ } catch (e: any) {
262
+ const code = e?.name === "SecurityError" ? "cross_origin" as const : "runtime_error" as const;
263
+ return { ok: false as const, code, message: e?.message ?? String(e) };
264
+ }
265
+ }
266
+
267
+ async listFrames() {
268
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill: not implemented" };
269
+ }
270
+
271
+ async accessibilitySnapshot(_opts?: unknown) {
272
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill: not implemented" };
273
+ }
274
+
275
+ async setDownloadPolicy(_policy: unknown, _dir?: unknown) {
276
+ // No-op — iframe has no download lifecycle hook.
277
+ }
278
+
279
+ async waitForDownload(_opts?: unknown) {
280
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill: not implemented" };
281
+ }
282
+
283
+ async acceptPopup(_opts?: unknown) {
284
+ return { ok: false as const, code: "not_found" as const, message: "polyfill: no popup orchestration" };
285
+ }
286
+
287
+ async dismissPopup(_id?: unknown) {
288
+ // No-op.
289
+ }
290
+
291
+ async extendAdoptionTimeout(_id?: unknown, _ms?: unknown) {
292
+ return { ok: false as const, code: "not_found" as const, message: "polyfill: no popup orchestration" };
293
+ }
294
+
222
295
  async sendType(text: string) {
223
296
  if (!this.isReachable()) return;
224
297
  const doc = this._iframe!.contentDocument!;
@@ -313,6 +386,10 @@ function definePolyfillClass(): CustomElementConstructor {
313
386
  return [] as { level: string; args: string[]; ts: number }[];
314
387
  }
315
388
 
389
+ async getNavigationState() {
390
+ return { lastLoadEpoch: this._epoch, isLoading: this._isLoading, currentUrl: this._currentUrl };
391
+ }
392
+
316
393
  async screenshot(_args?: { format?: "png" | "jpeg"; quality?: number }) {
317
394
  return { ok: false as const, code: "not_supported" as const, message: "iframe polyfill does not support screenshot" };
318
395
  }