@harness-fe/runtime 3.3.0 → 3.4.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.
package/dist/index.d.ts CHANGED
@@ -6,3 +6,5 @@
6
6
  */
7
7
  export { RuntimeClient, tryInheritFromParent } from './client.js';
8
8
  export type { ClientOptions, ParentInheritance } from './client.js';
9
+ export { registerOverlayPlugin, getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
10
+ export type { OverlayPlugin, OverlayPluginContext, OverlayPluginSelectedElement, OverlayPluginSelector, OverlayPluginLogs, OverlayPluginGetLogsOptions, } from './pluginRegistry.js';
package/dist/index.js CHANGED
@@ -6,9 +6,17 @@
6
6
  */
7
7
  import { installOverlay } from './overlay.js';
8
8
  import { RuntimeClient, readInjectedConfig } from './client.js';
9
+ import { registerOverlayPlugin, drainPluginQueue, } from './pluginRegistry.js';
10
+ // Informational; keep in sync with package.json on release.
11
+ const VERSION = '3.3.0';
9
12
  const w = window;
10
13
  if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
11
14
  w.__harness_fe_started__ = true;
15
+ // Public global for runtime plugin registration. Works before or after the
16
+ // overlay mounts — the registry buffers and the overlay subscribes.
17
+ w.HarnessFE = { registerOverlayPlugin, version: VERSION };
18
+ // Drain any plugins queued before the runtime loaded.
19
+ drainPluginQueue(w.__HARNESS_FE_PLUGINS__);
12
20
  const cfg = readInjectedConfig();
13
21
  const client = new RuntimeClient(cfg);
14
22
  client.start();
@@ -21,3 +29,4 @@ if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
21
29
  w.__hfe_session_id__ = client.sessionId;
22
30
  }
23
31
  export { RuntimeClient, tryInheritFromParent } from './client.js';
32
+ export { registerOverlayPlugin, getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
package/dist/overlay.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * or out. State machine: idle → info → (picker → question) → flash → idle.
15
15
  */
16
16
  import { type TaskAttachment } from '@harness-fe/protocol';
17
+ import { type OverlayPluginLogs, type OverlayPluginGetLogsOptions } from './pluginRegistry.js';
17
18
  export interface OverlayClient {
18
19
  readonly projectId: string;
19
20
  readonly buildId?: string;
@@ -60,6 +61,14 @@ export declare function replayStrokes(ctx: CanvasRenderingContext2D, bgCanvas: H
60
61
  * Exported for testing.
61
62
  */
62
63
  export declare function finalizeAnnotation(): Promise<TaskAttachment | null>;
64
+ /**
65
+ * Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
66
+ * failure (cross-origin, test env, …) so plugins can degrade gracefully.
67
+ * Exported for testing.
68
+ */
69
+ export declare function captureElementPng(el: Element): Promise<TaskAttachment | null>;
70
+ /** Read recent buffered logs for the plugin context. Redacts network by default. */
71
+ export declare function collectLogs(opts?: OverlayPluginGetLogsOptions): OverlayPluginLogs;
63
72
  /**
64
73
  * Best-effort CSS path. Depth cap 12, id anchor short-circuits, ` >>> `
65
74
  * separates shadow-DOM boundaries.
package/dist/overlay.js CHANGED
@@ -13,9 +13,14 @@
13
13
  * Single Shadow DOM root attached to <body>; host page styles never leak in
14
14
  * or out. State machine: idle → info → (picker → question) → flash → idle.
15
15
  */
16
- import { EVENT_NAME } from '@harness-fe/protocol';
16
+ import { EVENT_NAME, } from '@harness-fe/protocol';
17
17
  import { snapdom } from '@zumer/snapdom';
18
18
  import { deriveDashboardUrl } from './dashboardUrl.js';
19
+ import { getCaptureStore } from './capture.js';
20
+ import { collectPageLoadSnapshot } from './snapshot.js';
21
+ import { getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
22
+ /** Repo URL surfaced in the overlay footer. */
23
+ const GITHUB_URL = 'https://github.com/Morphicai/harness-fe';
19
24
  const HOST_ID = '__harness_fe_overlay__';
20
25
  const MAX_OUTER_HTML = 2048;
21
26
  // Internal instrumentation attributes injected by our build plugin. Must be
@@ -242,6 +247,8 @@ export function installOverlay(client) {
242
247
  let statusPollTimer;
243
248
  /** Flattened PNG from the annotate step; null if user skipped. */
244
249
  let pendingAttachment = null;
250
+ /** Set while the picker is collecting an element for a `requiresElement` plugin. */
251
+ let pluginAwaitingElement = null;
245
252
  const setState = (next) => {
246
253
  state = next;
247
254
  infoCard.style.display = next === 'info' ? 'flex' : 'none';
@@ -314,6 +321,17 @@ export function installOverlay(client) {
314
321
  return;
315
322
  lockedEl = hoveredEl;
316
323
  setHighlight(lockedEl);
324
+ // A plugin requested the element — hand it straight to its onClick and
325
+ // skip the report/question flow entirely.
326
+ if (pluginAwaitingElement) {
327
+ const plugin = pluginAwaitingElement;
328
+ pluginAwaitingElement = null;
329
+ const el = lockedEl;
330
+ lockedEl = null;
331
+ setState('idle');
332
+ void invokePlugin(plugin, el);
333
+ return;
334
+ }
317
335
  // Go straight to the question step. Screenshots are now opt-in via
318
336
  // the "Add screenshot" button inside the question panel — users
319
337
  // shouldn't have to draw on every report.
@@ -339,6 +357,7 @@ export function installOverlay(client) {
339
357
  else if (state === 'picker' || state === 'question') {
340
358
  lockedEl = null;
341
359
  pendingAttachment = null;
360
+ pluginAwaitingElement = null;
342
361
  setState('info');
343
362
  }
344
363
  else if (state === 'info') {
@@ -425,6 +444,103 @@ export function installOverlay(client) {
425
444
  lines.push(`- daemon: ${client.getConnectionState()}`);
426
445
  return lines.join('\n') + '\n';
427
446
  };
447
+ // ─── Plugin support ──────────────────────────────────────────────────
448
+ const dashboardUrl = client.mcpUrl
449
+ ? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
450
+ : undefined;
451
+ /** Brief transient toast anchored near the FAB. */
452
+ const showToast = (message, kind = 'ok') => {
453
+ const el = document.createElement('div');
454
+ el.className = 'hfe-toast';
455
+ el.dataset.kind = kind;
456
+ el.textContent = message;
457
+ root.appendChild(el);
458
+ requestAnimationFrame(() => { el.dataset.show = '1'; });
459
+ setTimeout(() => {
460
+ el.dataset.show = '';
461
+ setTimeout(() => el.remove(), 250);
462
+ }, 2400);
463
+ };
464
+ const buildPluginContext = (selectedEl) => {
465
+ const selectedElement = selectedEl
466
+ ? {
467
+ el: selectedEl,
468
+ selector: {
469
+ comp: selectedEl.getAttribute('data-morphix-comp') ?? undefined,
470
+ loc: selectedEl.getAttribute('data-morphix-loc') ?? undefined,
471
+ css: buildCssPath(selectedEl),
472
+ },
473
+ outerHTML: truncate(stripInternalAttrs(selectedEl.outerHTML), MAX_OUTER_HTML),
474
+ rect: (() => {
475
+ const r = selectedEl.getBoundingClientRect();
476
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
477
+ })(),
478
+ }
479
+ : undefined;
480
+ return {
481
+ projectId: client.projectId,
482
+ displayName: client.displayName,
483
+ buildId: client.buildId,
484
+ parentProjectId: client.parentProjectId,
485
+ sessionId: client.sessionId,
486
+ tabId: client.tabId,
487
+ visitorId: client.visitorId,
488
+ userId: client.userId,
489
+ url: location.href,
490
+ connectionState: client.getConnectionState(),
491
+ dashboardUrl,
492
+ selectedElement,
493
+ snapshotMarkdown: buildSnapshot,
494
+ snapshot: () => collectPageLoadSnapshot(client.sessionId),
495
+ getLogs: (opts) => collectLogs(opts),
496
+ captureScreenshot: (el) => captureElementPng(el ?? selectedEl ?? document.body),
497
+ query: client.query ? client.query.bind(client) : undefined,
498
+ copyToClipboard: (text) => copyText(text),
499
+ toast: showToast,
500
+ };
501
+ };
502
+ const invokePlugin = async (plugin, selectedEl) => {
503
+ try {
504
+ await plugin.onClick(buildPluginContext(selectedEl));
505
+ }
506
+ catch (err) {
507
+ const msg = err instanceof Error ? err.message : String(err);
508
+ showToast(`${plugin.label}: ${msg}`, 'error');
509
+ }
510
+ };
511
+ /** (Re)render the plugin button group in the info card. */
512
+ const renderPluginButtons = () => {
513
+ const slot = infoCard.querySelector('[data-role=plugin-actions]');
514
+ if (!slot)
515
+ return;
516
+ const list = getOverlayPlugins();
517
+ slot.innerHTML = '';
518
+ slot.style.display = list.length ? '' : 'none';
519
+ for (const plugin of list) {
520
+ const btn = document.createElement('button');
521
+ btn.className = 'secondary';
522
+ btn.type = 'button';
523
+ btn.dataset.pluginId = plugin.id;
524
+ btn.textContent = `${plugin.icon ? plugin.icon + ' ' : ''}${plugin.label}`;
525
+ btn.addEventListener('click', () => {
526
+ if (plugin.requiresElement) {
527
+ pluginAwaitingElement = plugin;
528
+ setState('picker');
529
+ }
530
+ else {
531
+ setState('idle');
532
+ void invokePlugin(plugin);
533
+ }
534
+ });
535
+ slot.appendChild(btn);
536
+ }
537
+ };
538
+ renderPluginButtons();
539
+ const unsubscribePlugins = subscribeOverlayPlugins(() => {
540
+ // Only the info card shows plugin buttons; re-render whenever the set changes.
541
+ renderPluginButtons();
542
+ });
543
+ void unsubscribePlugins; // overlay lives for the page lifetime; no teardown path
428
544
  // ─── Reports rendering ───────────────────────────────────────────────
429
545
  let editingTaskId = null;
430
546
  let deleteConfirmId = null;
@@ -653,9 +769,6 @@ export function installOverlay(client) {
653
769
  // the plugin / runtime config).
654
770
  {
655
771
  const dashboardBtn = infoCard.querySelector('[data-role=open-dashboard]');
656
- const dashboardUrl = client.mcpUrl
657
- ? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
658
- : undefined;
659
772
  if (dashboardUrl) {
660
773
  dashboardBtn.style.display = '';
661
774
  dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
@@ -671,6 +784,19 @@ export function installOverlay(client) {
671
784
  });
672
785
  }
673
786
  }
787
+ // GitHub promo link — anchor navigates natively; this fallback covers
788
+ // sandboxed iframes / popup blockers by copying the URL instead.
789
+ infoCard.querySelector('[data-role=github]').addEventListener('click', (ev) => {
790
+ try {
791
+ const opened = window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
792
+ if (opened)
793
+ ev.preventDefault();
794
+ }
795
+ catch {
796
+ ev.preventDefault();
797
+ void copyText(GITHUB_URL, ev.currentTarget);
798
+ }
799
+ });
674
800
  reportsCard.querySelector('[data-role=back]').addEventListener('click', () => setState('info'));
675
801
  reportsCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
676
802
  reportsCard.querySelector('[data-role=refresh]').addEventListener('click', () => void refreshReports());
@@ -688,6 +814,7 @@ export function installOverlay(client) {
688
814
  }
689
815
  pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
690
816
  lockedEl = null;
817
+ pluginAwaitingElement = null;
691
818
  setState('info');
692
819
  });
693
820
  questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
@@ -1037,6 +1164,62 @@ export async function finalizeAnnotation() {
1037
1164
  resetAnnotateStrokes();
1038
1165
  return att;
1039
1166
  }
1167
+ // ─── Plugin helpers (screenshot / logs) ───────────────────────────────────
1168
+ /**
1169
+ * Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
1170
+ * failure (cross-origin, test env, …) so plugins can degrade gracefully.
1171
+ * Exported for testing.
1172
+ */
1173
+ export async function captureElementPng(el) {
1174
+ try {
1175
+ const result = await snapdom(el, { fast: true });
1176
+ const canvas = await result.toCanvas();
1177
+ const dataUrl = canvas.toDataURL('image/png', 0.85);
1178
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
1179
+ return {
1180
+ id: `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1181
+ kind: 'screenshot',
1182
+ data: base64,
1183
+ width: canvas.width || 1,
1184
+ height: canvas.height || 1,
1185
+ };
1186
+ }
1187
+ catch {
1188
+ return null;
1189
+ }
1190
+ }
1191
+ const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
1192
+ /** Strip bodies + auth/cookie headers from a network entry. */
1193
+ function redactNetworkEntry(e) {
1194
+ const out = { ...e };
1195
+ delete out.requestBody;
1196
+ delete out.responseBody;
1197
+ delete out.requestBodyTruncated;
1198
+ delete out.responseBodyTruncated;
1199
+ for (const key of ['requestHeaders', 'responseHeaders']) {
1200
+ const h = out[key];
1201
+ if (h) {
1202
+ const safe = {};
1203
+ for (const [k, v] of Object.entries(h)) {
1204
+ if (!SENSITIVE_HEADER_RE.test(k))
1205
+ safe[k] = v;
1206
+ }
1207
+ out[key] = safe;
1208
+ }
1209
+ }
1210
+ return out;
1211
+ }
1212
+ /** Read recent buffered logs for the plugin context. Redacts network by default. */
1213
+ export function collectLogs(opts = {}) {
1214
+ const store = getCaptureStore();
1215
+ const redact = opts.redact !== false;
1216
+ const network = store.network.tail(opts.network ?? 0);
1217
+ return {
1218
+ console: store.console.tail(opts.console ?? 0),
1219
+ errors: store.errors.tail(opts.errors ?? 0),
1220
+ network: redact ? network.map(redactNetworkEntry) : network,
1221
+ };
1222
+ }
1040
1223
  // ─── DOM builders ────────────────────────────────────────────────────────
1041
1224
  function buildStyle() {
1042
1225
  const style = document.createElement('style');
@@ -1263,6 +1446,47 @@ function buildStyle() {
1263
1446
  border-color: rgba(52, 211, 153, 0.3);
1264
1447
  }
1265
1448
 
1449
+ .info-card .plugin-actions {
1450
+ display: flex;
1451
+ flex-direction: column;
1452
+ gap: 8px;
1453
+ margin-top: 8px;
1454
+ padding-top: 8px;
1455
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
1456
+ }
1457
+ .info-card .promo {
1458
+ margin-top: 8px;
1459
+ text-align: center;
1460
+ }
1461
+ .info-card .promo-link {
1462
+ color: #a1a1aa;
1463
+ font-size: 11px;
1464
+ text-decoration: none;
1465
+ opacity: 0.7;
1466
+ }
1467
+ .info-card .promo-link:hover { color: #f4f4f5; opacity: 1; }
1468
+
1469
+ .hfe-toast {
1470
+ position: fixed;
1471
+ bottom: 64px;
1472
+ left: 50%;
1473
+ transform: translate(-50%, 8px);
1474
+ max-width: 320px;
1475
+ padding: 8px 14px;
1476
+ background: #111827;
1477
+ color: #f4f4f5;
1478
+ border: 1px solid rgba(255, 255, 255, 0.12);
1479
+ border-radius: 10px;
1480
+ font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1481
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
1482
+ opacity: 0;
1483
+ transition: opacity 0.2s ease, transform 0.2s ease;
1484
+ z-index: 2147483647;
1485
+ pointer-events: none;
1486
+ }
1487
+ .hfe-toast[data-show="1"] { opacity: 1; transform: translate(-50%, 0); }
1488
+ .hfe-toast[data-kind="error"] { border-color: rgba(248, 113, 113, 0.4); color: #fca5a5; }
1489
+
1266
1490
  .picker-bar {
1267
1491
  position: fixed;
1268
1492
  top: 12px;
@@ -1727,6 +1951,10 @@ function buildInfoCard() {
1727
1951
  <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
1728
1952
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
1729
1953
  </div>
1954
+ <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
1955
+ <div class="promo">
1956
+ <a class="promo-link" data-role="github" href="${GITHUB_URL}" target="_blank" rel="noopener noreferrer">⭐ Harness-FE on GitHub</a>
1957
+ </div>
1730
1958
  `;
1731
1959
  return card;
1732
1960
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Overlay plugin registry.
3
+ *
4
+ * Lets developers extend the in-page "H" overlay with their own action buttons
5
+ * — e.g. "send this scene to Slack" / "create a Jira issue" — without forking
6
+ * `@harness-fe/runtime`. A plugin is just a button label + an `onClick(ctx)`
7
+ * handler; the handler receives an {@link OverlayPluginContext} with on-demand,
8
+ * redaction-aware access to the current scene, logs, screenshot, and selected
9
+ * element.
10
+ *
11
+ * Registration order doesn't matter: plugins registered before the overlay
12
+ * mounts are buffered here, and the overlay subscribes so anything registered
13
+ * later renders immediately. See `index.ts` for the `window.HarnessFE` global
14
+ * and the `window.__HARNESS_FE_PLUGINS__` pre-boot queue.
15
+ */
16
+ import type { PageLoadPayload, ConsoleEntry, NetworkEntry, ErrorEntry, TaskAttachment } from '@harness-fe/protocol';
17
+ /** Selector descriptor for a picked element (mirrors TaskSubmitPayload.selector). */
18
+ export interface OverlayPluginSelector {
19
+ /** Source location `file:line:col` from the build transform, if present. */
20
+ loc?: string;
21
+ /** Component display name from the build transform, if present. */
22
+ comp?: string;
23
+ /** Best-effort CSS path. */
24
+ css: string;
25
+ }
26
+ /** The element the user picked (only present for `requiresElement` plugins). */
27
+ export interface OverlayPluginSelectedElement {
28
+ /** Live DOM node. */
29
+ el: Element;
30
+ selector: OverlayPluginSelector;
31
+ /** outerHTML with internal instrumentation attrs stripped + truncated. */
32
+ outerHTML: string;
33
+ rect: {
34
+ x: number;
35
+ y: number;
36
+ width: number;
37
+ height: number;
38
+ };
39
+ }
40
+ export interface OverlayPluginLogs {
41
+ console: ConsoleEntry[];
42
+ network: NetworkEntry[];
43
+ errors: ErrorEntry[];
44
+ }
45
+ export interface OverlayPluginGetLogsOptions {
46
+ /** Max console entries (newest last). Default 0 — pass a count to include. */
47
+ console?: number;
48
+ /** Max network entries. Default 0. */
49
+ network?: number;
50
+ /** Max error entries. Default 0. */
51
+ errors?: number;
52
+ /**
53
+ * When `true` (the default), network entries are reduced to metadata:
54
+ * request/response bodies are dropped and `authorization` / `cookie`
55
+ * headers are stripped. Set `false` to receive raw entries — only do this
56
+ * when you control the destination, as bodies may contain secrets.
57
+ */
58
+ redact?: boolean;
59
+ }
60
+ /**
61
+ * Per-invocation context handed to a plugin's `onClick`. Getters are lazy: the
62
+ * snapshot / logs / screenshot are only computed when you call them, so a
63
+ * plugin pays for exactly what it uses.
64
+ */
65
+ export interface OverlayPluginContext {
66
+ readonly projectId: string;
67
+ readonly displayName?: string;
68
+ readonly buildId?: string;
69
+ readonly parentProjectId?: string;
70
+ readonly sessionId: string;
71
+ readonly tabId: string;
72
+ readonly visitorId?: string;
73
+ readonly userId?: string;
74
+ /** `location.href` at click time. */
75
+ readonly url: string;
76
+ readonly connectionState: 'connecting' | 'open' | 'closed';
77
+ /** Deep link to this session in the daemon dashboard, if the address is known. */
78
+ readonly dashboardUrl?: string;
79
+ /** Present only for plugins declared with `requiresElement: true`. */
80
+ readonly selectedElement?: OverlayPluginSelectedElement;
81
+ /** Shareable markdown summary (project / build / session / tab / url / time / conn). */
82
+ snapshotMarkdown(): string;
83
+ /** Structured page-load snapshot: page / viewport / storage / performance. */
84
+ snapshot(): PageLoadPayload;
85
+ /** Recent buffered logs. Redacted by default — see {@link OverlayPluginGetLogsOptions}. */
86
+ getLogs(opts?: OverlayPluginGetLogsOptions): OverlayPluginLogs;
87
+ /** Rasterize an element (default: the picked element, else `document.body`) to a PNG attachment. */
88
+ captureScreenshot(el?: Element): Promise<TaskAttachment | null>;
89
+ /** Daemon RPC over the whitelisted channel (e.g. `tasks.mine`). Undefined if unavailable. */
90
+ query?: <TResult = unknown>(method: string, args?: unknown) => Promise<TResult>;
91
+ /** Copy text to the clipboard (best-effort). */
92
+ copyToClipboard(text: string): Promise<void>;
93
+ /** Show a brief feedback toast on the overlay. */
94
+ toast(message: string, kind?: 'ok' | 'error'): void;
95
+ }
96
+ export interface OverlayPlugin {
97
+ /** Unique id. Re-registering the same id replaces the previous plugin. */
98
+ id: string;
99
+ /** Button label shown in the info card. */
100
+ label: string;
101
+ /** Optional leading icon (emoji or single char). */
102
+ icon?: string;
103
+ /**
104
+ * When `true`, clicking the button first enters element-picker mode; the
105
+ * picked element is then available as `ctx.selectedElement` in `onClick`.
106
+ */
107
+ requiresElement?: boolean;
108
+ /** Invoked when the button is clicked. May be async; rejections are toasted. */
109
+ onClick(ctx: OverlayPluginContext): void | Promise<void>;
110
+ }
111
+ /**
112
+ * Register an overlay plugin. Returns an unregister function (handy for HMR
113
+ * cleanup). Registering an id that already exists replaces it.
114
+ */
115
+ export declare function registerOverlayPlugin(plugin: OverlayPlugin): () => void;
116
+ /** Current plugins, in registration order. */
117
+ export declare function getOverlayPlugins(): OverlayPlugin[];
118
+ /** Subscribe to registry changes (add / replace / remove). Returns an unsubscribe fn. */
119
+ export declare function subscribeOverlayPlugins(fn: () => void): () => void;
120
+ /**
121
+ * Drain a pre-boot queue of plugins. Developers whose script may run before the
122
+ * runtime loads can push plain plugin objects onto
123
+ * `window.__HARNESS_FE_PLUGINS__`; `index.ts` calls this once on boot.
124
+ */
125
+ export declare function drainPluginQueue(queue: unknown): void;
126
+ /** Test-only: clear all plugins and listeners. */
127
+ export declare function __resetOverlayPlugins(): void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Overlay plugin registry.
3
+ *
4
+ * Lets developers extend the in-page "H" overlay with their own action buttons
5
+ * — e.g. "send this scene to Slack" / "create a Jira issue" — without forking
6
+ * `@harness-fe/runtime`. A plugin is just a button label + an `onClick(ctx)`
7
+ * handler; the handler receives an {@link OverlayPluginContext} with on-demand,
8
+ * redaction-aware access to the current scene, logs, screenshot, and selected
9
+ * element.
10
+ *
11
+ * Registration order doesn't matter: plugins registered before the overlay
12
+ * mounts are buffered here, and the overlay subscribes so anything registered
13
+ * later renders immediately. See `index.ts` for the `window.HarnessFE` global
14
+ * and the `window.__HARNESS_FE_PLUGINS__` pre-boot queue.
15
+ */
16
+ const plugins = new Map();
17
+ const listeners = new Set();
18
+ function notify() {
19
+ for (const fn of listeners) {
20
+ try {
21
+ fn();
22
+ }
23
+ catch {
24
+ /* a bad listener must not break registration */
25
+ }
26
+ }
27
+ }
28
+ /**
29
+ * Register an overlay plugin. Returns an unregister function (handy for HMR
30
+ * cleanup). Registering an id that already exists replaces it.
31
+ */
32
+ export function registerOverlayPlugin(plugin) {
33
+ if (!plugin || typeof plugin.id !== 'string' || plugin.id === '') {
34
+ throw new Error('registerOverlayPlugin: plugin.id is required');
35
+ }
36
+ if (typeof plugin.onClick !== 'function') {
37
+ throw new Error(`registerOverlayPlugin: plugin "${plugin.id}" needs an onClick handler`);
38
+ }
39
+ plugins.set(plugin.id, plugin);
40
+ notify();
41
+ return () => {
42
+ if (plugins.get(plugin.id) === plugin) {
43
+ plugins.delete(plugin.id);
44
+ notify();
45
+ }
46
+ };
47
+ }
48
+ /** Current plugins, in registration order. */
49
+ export function getOverlayPlugins() {
50
+ return [...plugins.values()];
51
+ }
52
+ /** Subscribe to registry changes (add / replace / remove). Returns an unsubscribe fn. */
53
+ export function subscribeOverlayPlugins(fn) {
54
+ listeners.add(fn);
55
+ return () => {
56
+ listeners.delete(fn);
57
+ };
58
+ }
59
+ /**
60
+ * Drain a pre-boot queue of plugins. Developers whose script may run before the
61
+ * runtime loads can push plain plugin objects onto
62
+ * `window.__HARNESS_FE_PLUGINS__`; `index.ts` calls this once on boot.
63
+ */
64
+ export function drainPluginQueue(queue) {
65
+ if (!Array.isArray(queue))
66
+ return;
67
+ for (const p of queue) {
68
+ try {
69
+ registerOverlayPlugin(p);
70
+ }
71
+ catch {
72
+ /* skip malformed queued entries */
73
+ }
74
+ }
75
+ }
76
+ /** Test-only: clear all plugins and listeners. */
77
+ export function __resetOverlayPlugins() {
78
+ plugins.clear();
79
+ listeners.clear();
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/runtime",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Browser-side SDK injected into the dev page. Connects to the MCP server via WebSocket and executes commands.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,8 +30,8 @@
30
30
  "dependencies": {
31
31
  "@zumer/snapdom": "^2.12.0",
32
32
  "rrweb": "2.0.0-alpha.4",
33
- "@harness-fe/protocol": "3.2.0",
34
- "@harness-fe/sandbox": "^3.2.0"
33
+ "@harness-fe/sandbox": "^3.2.0",
34
+ "@harness-fe/protocol": "3.2.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "happy-dom": "^20.9.0",
package/src/index.ts CHANGED
@@ -7,15 +7,31 @@
7
7
 
8
8
  import { installOverlay } from './overlay.js';
9
9
  import { RuntimeClient, readInjectedConfig } from './client.js';
10
+ import {
11
+ registerOverlayPlugin,
12
+ drainPluginQueue,
13
+ type OverlayPlugin,
14
+ } from './pluginRegistry.js';
15
+
16
+ // Informational; keep in sync with package.json on release.
17
+ const VERSION = '3.3.0';
10
18
 
11
19
  const w = window as unknown as {
12
20
  __harness_fe_started__?: boolean;
13
21
  __harness_fe_client__?: RuntimeClient;
14
22
  __hfe_session_id__?: string;
23
+ __HARNESS_FE_PLUGINS__?: OverlayPlugin[];
24
+ HarnessFE?: { registerOverlayPlugin: typeof registerOverlayPlugin; version: string };
15
25
  };
16
26
 
17
27
  if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
18
28
  w.__harness_fe_started__ = true;
29
+ // Public global for runtime plugin registration. Works before or after the
30
+ // overlay mounts — the registry buffers and the overlay subscribes.
31
+ w.HarnessFE = { registerOverlayPlugin, version: VERSION };
32
+ // Drain any plugins queued before the runtime loaded.
33
+ drainPluginQueue(w.__HARNESS_FE_PLUGINS__);
34
+
19
35
  const cfg = readInjectedConfig();
20
36
  const client = new RuntimeClient(cfg);
21
37
  client.start();
@@ -30,3 +46,16 @@ if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
30
46
 
31
47
  export { RuntimeClient, tryInheritFromParent } from './client.js';
32
48
  export type { ClientOptions, ParentInheritance } from './client.js';
49
+ export {
50
+ registerOverlayPlugin,
51
+ getOverlayPlugins,
52
+ subscribeOverlayPlugins,
53
+ } from './pluginRegistry.js';
54
+ export type {
55
+ OverlayPlugin,
56
+ OverlayPluginContext,
57
+ OverlayPluginSelectedElement,
58
+ OverlayPluginSelector,
59
+ OverlayPluginLogs,
60
+ OverlayPluginGetLogsOptions,
61
+ } from './pluginRegistry.js';