yaver-feedback-react-native 0.2.0 → 0.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/app.plugin.js CHANGED
@@ -1,18 +1,30 @@
1
1
  /**
2
- * Expo config plugin for @yaver/feedback-react-native.
2
+ * Expo config plugin for yaver-feedback-react-native.
3
3
  *
4
- * Adds required native permissions for the feedback SDK:
5
- * - iOS: Camera + Microphone usage descriptions
6
- * - Android: CAMERA + RECORD_AUDIO permissions
4
+ * Adds:
5
+ * - iOS/Android permissions for camera + microphone (feedback screenshots/voice)
6
+ * - iOS: YaverHotReload native module for Hermes bundle hot reload
7
+ * - AppDelegate hook to load hot-reloaded bundles on startup
7
8
  *
8
9
  * Usage in app.json:
9
- * { "expo": { "plugins": ["@yaver/feedback-react-native"] } }
10
+ * { "expo": { "plugins": ["yaver-feedback-react-native"] } }
10
11
  */
12
+ // Resolve @expo/config-plugins from the host project's node_modules
13
+ // (not from the SDK's directory, which may be symlinked)
14
+ const configPluginsPath = require.resolve("@expo/config-plugins", {
15
+ paths: [process.cwd()],
16
+ });
11
17
  const {
12
18
  withInfoPlist,
13
19
  withAndroidManifest,
20
+ withXcodeProject,
21
+ withAppDelegate,
22
+ withMainApplication,
23
+ withDangerousMod,
14
24
  createRunOncePlugin,
15
- } = require("@expo/config-plugins");
25
+ } = require(configPluginsPath);
26
+ const path = require("path");
27
+ const fs = require("fs");
16
28
 
17
29
  const pkg = require("./package.json");
18
30
 
@@ -57,9 +69,255 @@ function withYaverFeedbackAndroid(config) {
57
69
  });
58
70
  }
59
71
 
60
- function withYaverFeedback(config) {
72
+ /**
73
+ * Copy YaverHotReload native module files into the iOS project directory.
74
+ * Uses withDangerousMod to copy files during prebuild. The files are
75
+ * automatically picked up by Xcode since they're in the project directory.
76
+ */
77
+ function withYaverHotReloadNativeModule(config) {
78
+ return withDangerousMod(config, [
79
+ "ios",
80
+ (config) => {
81
+ const sdkIosDir = path.resolve(__dirname, "ios");
82
+ const appName = config.modRequest.projectName || "SFMG";
83
+ const targetDir = path.join(
84
+ config.modRequest.platformProjectRoot,
85
+ appName
86
+ );
87
+
88
+ const filesToCopy = ["YaverHotReload.swift", "YaverHotReload.m"];
89
+ for (const fileName of filesToCopy) {
90
+ const src = path.join(sdkIosDir, fileName);
91
+ const dst = path.join(targetDir, fileName);
92
+ if (fs.existsSync(src) && !fs.existsSync(dst)) {
93
+ fs.copyFileSync(src, dst);
94
+ }
95
+ }
96
+
97
+ return config;
98
+ },
99
+ ]);
100
+ }
101
+
102
+ /**
103
+ * Patch AppDelegate to:
104
+ * 1. Return hot-reloaded bundle URL on startup (so reloaded bundle persists)
105
+ * 2. Handle YaverHotReloadBundle notification to recreate the RN bridge
106
+ * with the new bundle (enables N reloads without app restart)
107
+ *
108
+ * Uses the same pattern as Yaver's own AppDelegate: tear down old bridge,
109
+ * create new ExpoReactNativeFactory with overrideBundleURL, startReactNative.
110
+ */
111
+ function withYaverAppDelegateHook(config) {
112
+ return withAppDelegate(config, (config) => {
113
+ const contents = config.modResults.contents;
114
+
115
+ // Only patch if not already patched
116
+ if (contents.includes("YaverHotReload")) {
117
+ return config;
118
+ }
119
+
120
+ // For Swift AppDelegate (Expo SDK 50+)
121
+ let patched = contents;
122
+
123
+ // 1. Hook bundleURL() to return hot bundle on startup
124
+ if (patched.includes("func bundleURL()")) {
125
+ patched = patched.replace(
126
+ /func bundleURL\(\) -> URL\? \{/,
127
+ `func bundleURL() -> URL? {
128
+ // Yaver Feedback SDK: load hot-reloaded bundle if available
129
+ if let yaverBundle = YaverHotReload.bundleURL() { return yaverBundle }`
130
+ );
131
+ }
132
+
133
+ // 2. Add reload notification handler and bridge recreation logic
134
+ // Insert before the closing brace of the class
135
+ const classCloseIndex = patched.lastIndexOf("}");
136
+ if (classCloseIndex > 0) {
137
+ const reloadHandler = `
138
+ // MARK: - Yaver Feedback SDK Hot Reload
139
+
140
+ private var yaverIsReloading = false
141
+
142
+ private func setupYaverHotReload() {
143
+ NotificationCenter.default.addObserver(
144
+ self,
145
+ selector: #selector(yaverHandleHotReload(_:)),
146
+ name: Notification.Name("YaverHotReloadBundle"),
147
+ object: nil
148
+ )
149
+ }
150
+
151
+ @objc private func yaverHandleHotReload(_ notification: Notification) {
152
+ guard !yaverIsReloading else { return }
153
+ yaverIsReloading = true
154
+
155
+ guard let bundlePath = notification.userInfo?["bundlePath"] as? String else {
156
+ yaverIsReloading = false
157
+ return
158
+ }
159
+
160
+ let bundleURL = URL(fileURLWithPath: bundlePath)
161
+ guard FileManager.default.fileExists(atPath: bundlePath) else {
162
+ NSLog("[YaverHotReload] bundle not found at %@", bundlePath)
163
+ yaverIsReloading = false
164
+ return
165
+ }
166
+
167
+ NSLog("[YaverHotReload] reloading bridge with %@", bundlePath)
168
+
169
+ guard let window = self.window else {
170
+ yaverIsReloading = false
171
+ return
172
+ }
173
+
174
+ // Show loading placeholder
175
+ let placeholder = UIView(frame: window.bounds)
176
+ placeholder.backgroundColor = .black
177
+ let spinner = UIActivityIndicatorView(style: .large)
178
+ spinner.color = .white
179
+ spinner.center = placeholder.center
180
+ spinner.startAnimating()
181
+ placeholder.addSubview(spinner)
182
+ window.rootViewController?.view = placeholder
183
+
184
+ // Brief delay for old bridge to tear down
185
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
186
+ guard let self = self else { return }
187
+
188
+ let delegate = ReactNativeDelegate()
189
+ delegate.overrideBundleURL = bundleURL
190
+ delegate.dependencyProvider = RCTAppDependencyProvider()
191
+
192
+ let factory = ExpoReactNativeFactory(delegate: delegate)
193
+ self.reactNativeDelegate = delegate
194
+ self.reactNativeFactory = factory
195
+ self.bindReactNativeFactory(factory)
196
+
197
+ factory.startReactNative(
198
+ withModuleName: "main",
199
+ in: window,
200
+ launchOptions: nil
201
+ )
202
+ self.yaverIsReloading = false
203
+ NSLog("[YaverHotReload] bridge recreated successfully")
204
+ }
205
+ }
206
+ `;
207
+
208
+ patched =
209
+ patched.slice(0, classCloseIndex) +
210
+ reloadHandler +
211
+ patched.slice(classCloseIndex);
212
+ }
213
+
214
+ // 3. Call setupYaverHotReload() in didFinishLaunchingWithOptions
215
+ if (patched.includes("super.application(application, didFinishLaunchingWithOptions:")) {
216
+ patched = patched.replace(
217
+ "super.application(application, didFinishLaunchingWithOptions:",
218
+ "setupYaverHotReload()\n return super.application(application, didFinishLaunchingWithOptions:"
219
+ );
220
+ // Remove the duplicate "return" if the original already had one
221
+ patched = patched.replace("return setupYaverHotReload()", "setupYaverHotReload()");
222
+ }
223
+
224
+ config.modResults.contents = patched;
225
+ return config;
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Copy Android native module source files and register the package.
231
+ * Also patches MainApplication to use hot-reloaded bundle on startup.
232
+ */
233
+ function withYaverAndroidHotReload(config) {
234
+ // Copy Java source files
235
+ config = withDangerousMod(config, [
236
+ "android",
237
+ (config) => {
238
+ const sdkAndroidDir = path.resolve(__dirname, "android", "src", "main", "java", "io", "yaver", "feedback");
239
+ const targetDir = path.join(
240
+ config.modRequest.platformProjectRoot,
241
+ "app", "src", "main", "java", "io", "yaver", "feedback"
242
+ );
243
+
244
+ if (fs.existsSync(sdkAndroidDir)) {
245
+ fs.mkdirSync(targetDir, { recursive: true });
246
+ for (const file of fs.readdirSync(sdkAndroidDir)) {
247
+ fs.copyFileSync(
248
+ path.join(sdkAndroidDir, file),
249
+ path.join(targetDir, file)
250
+ );
251
+ }
252
+ }
253
+ return config;
254
+ },
255
+ ]);
256
+
257
+ // Patch MainApplication to register the package and use hot bundle
258
+ config = withMainApplication(config, (config) => {
259
+ let contents = config.modResults.contents;
260
+
261
+ if (contents.includes("YaverHotReload")) {
262
+ return config;
263
+ }
264
+
265
+ // Add import
266
+ contents = contents.replace(
267
+ "import com.facebook.react.ReactApplication",
268
+ "import com.facebook.react.ReactApplication;\nimport io.yaver.feedback.YaverHotReloadPackage;\nimport io.yaver.feedback.YaverHotReloadModule;"
269
+ );
270
+
271
+ // Register package in getPackages()
272
+ if (contents.includes("packages.add(")) {
273
+ // Find the last packages.add() and add ours after
274
+ const lastAdd = contents.lastIndexOf("packages.add(");
275
+ const lineEnd = contents.indexOf("\n", lastAdd);
276
+ contents =
277
+ contents.slice(0, lineEnd + 1) +
278
+ " packages.add(new YaverHotReloadPackage());\n" +
279
+ contents.slice(lineEnd + 1);
280
+ }
281
+
282
+ // Override getJSBundleFile to check for hot bundle
283
+ if (contents.includes("getJSMainModuleName()") && !contents.includes("getJSBundleFile")) {
284
+ const mainModuleIdx = contents.indexOf("getJSMainModuleName()");
285
+ const methodStart = contents.lastIndexOf("@Override", mainModuleIdx);
286
+ contents =
287
+ contents.slice(0, methodStart) +
288
+ `@Override
289
+ protected String getJSBundleFile() {
290
+ // Yaver Feedback SDK: load hot-reloaded bundle if available
291
+ java.io.File hotBundle = YaverHotReloadModule.getSavedBundleFile(getApplicationContext());
292
+ if (hotBundle != null) return hotBundle.getAbsolutePath();
293
+ return super.getJSBundleFile();
294
+ }
295
+
296
+ ` +
297
+ contents.slice(methodStart);
298
+ }
299
+
300
+ config.modResults.contents = contents;
301
+ return config;
302
+ });
303
+
304
+ return config;
305
+ }
306
+
307
+ function withYaverFeedback(config, props) {
61
308
  config = withYaverFeedbackIOS(config);
62
309
  config = withYaverFeedbackAndroid(config);
310
+
311
+ // Hot reload native module is opt-in via enableHotReload: true
312
+ // Skip by default — apps running inside Yaver container get hot reload
313
+ // via YaverBundleLoader, standalone dev builds use DevSettings.reload()
314
+ const enableHotReload = props?.enableHotReload === true;
315
+ if (enableHotReload) {
316
+ config = withYaverHotReloadNativeModule(config);
317
+ config = withYaverAppDelegateHook(config);
318
+ config = withYaverAndroidHotReload(config);
319
+ }
320
+
63
321
  return config;
64
322
  }
65
323
 
@@ -0,0 +1,16 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(YaverHotReload, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(loadBundle:(NSString *)urlString
6
+ headers:(NSDictionary *)headers
7
+ resolver:(RCTPromiseResolveBlock)resolve
8
+ rejecter:(RCTPromiseRejectBlock)reject)
9
+
10
+ RCT_EXTERN_METHOD(hasBundle:(RCTPromiseResolveBlock)resolve
11
+ rejecter:(RCTPromiseRejectBlock)reject)
12
+
13
+ RCT_EXTERN_METHOD(clearBundle:(RCTPromiseResolveBlock)resolve
14
+ rejecter:(RCTPromiseRejectBlock)reject)
15
+
16
+ @end
@@ -0,0 +1,112 @@
1
+ import Foundation
2
+ import React
3
+ import UIKit
4
+
5
+ /**
6
+ * Hot reload native module for the Yaver Feedback SDK.
7
+ *
8
+ * Downloads a Hermes bytecode bundle from the agent, saves it to Documents,
9
+ * and posts a notification that the AppDelegate handler (injected by the Expo
10
+ * config plugin) catches to recreate the RN bridge with the new bundle.
11
+ *
12
+ * Supports N reloads — each reload tears down the old bridge and creates
13
+ * a fresh one pointing to the updated bundle file.
14
+ */
15
+ @objc(YaverHotReload)
16
+ class YaverHotReload: NSObject {
17
+
18
+ static let bundleDir = "yaver-hot-reload"
19
+ static let bundleFile = "main.jsbundle"
20
+ static let reloadNotification = Notification.Name("YaverHotReloadBundle")
21
+
22
+ override static func requiresMainQueueSetup() -> Bool { return true }
23
+
24
+ /// Download a Hermes bundle from the agent and trigger a bridge reload.
25
+ @objc func loadBundle(_ urlString: String,
26
+ headers: NSDictionary?,
27
+ resolver resolve: @escaping RCTPromiseResolveBlock,
28
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
29
+ guard let bundleURL = URL(string: urlString) else {
30
+ reject("INVALID_URL", "Invalid bundle URL", nil); return
31
+ }
32
+
33
+ var request = URLRequest(url: bundleURL)
34
+ request.timeoutInterval = 60
35
+ if let headers = headers as? [String: String] {
36
+ for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
37
+ }
38
+
39
+ NSLog("[YaverHotReload] downloading bundle from %@...", urlString)
40
+ URLSession.shared.dataTask(with: request) { data, response, error in
41
+ if let error = error {
42
+ reject("DOWNLOAD_FAILED", error.localizedDescription, error); return
43
+ }
44
+ guard let data = data, data.count > 0 else {
45
+ reject("EMPTY_BUNDLE", "Empty bundle response", nil); return
46
+ }
47
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
48
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
49
+ reject("HTTP_ERROR", "Status \(code)", nil); return
50
+ }
51
+
52
+ // Basic Hermes bytecode validation
53
+ if data.count >= 12 {
54
+ let magic: UInt32 = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
55
+ if magic == 0x1F1903C1 {
56
+ let bcVersion: UInt32 = data.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt32.self) }
57
+ NSLog("[YaverHotReload] Hermes bytecode BC%d, %d bytes", bcVersion, data.count)
58
+ } else {
59
+ NSLog("[YaverHotReload] WARNING: not Hermes bytecode (magic=0x%08X)", magic)
60
+ }
61
+ }
62
+
63
+ do {
64
+ let savePath = YaverHotReload.savedBundlePath()
65
+ let dir = savePath.deletingLastPathComponent()
66
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
67
+ try data.write(to: savePath, options: .atomic)
68
+
69
+ NSLog("[YaverHotReload] saved %d bytes, posting reload notification", data.count)
70
+ resolve(["loaded": true, "size": data.count])
71
+
72
+ // Post notification — AppDelegate handler recreates the bridge
73
+ DispatchQueue.main.async {
74
+ NotificationCenter.default.post(
75
+ name: YaverHotReload.reloadNotification,
76
+ object: nil,
77
+ userInfo: ["bundlePath": savePath.path]
78
+ )
79
+ }
80
+ } catch {
81
+ reject("SAVE_FAILED", error.localizedDescription, error)
82
+ }
83
+ }.resume()
84
+ }
85
+
86
+ @objc func hasBundle(_ resolve: @escaping RCTPromiseResolveBlock,
87
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
88
+ resolve(FileManager.default.fileExists(atPath: YaverHotReload.savedBundlePath().path))
89
+ }
90
+
91
+ @objc func clearBundle(_ resolve: @escaping RCTPromiseResolveBlock,
92
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
93
+ let dir = YaverHotReload.savedBundlePath().deletingLastPathComponent()
94
+ try? FileManager.default.removeItem(at: dir)
95
+ resolve(true)
96
+ }
97
+
98
+ // MARK: - Static helpers
99
+
100
+ static func savedBundlePath() -> URL {
101
+ let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
102
+ return docs
103
+ .appendingPathComponent(bundleDir, isDirectory: true)
104
+ .appendingPathComponent(bundleFile)
105
+ }
106
+
107
+ /// Returns the hot-reloaded bundle URL if one exists on disk.
108
+ @objc static func bundleURL() -> URL? {
109
+ let path = savedBundlePath()
110
+ return FileManager.default.fileExists(atPath: path.path) ? path : nil
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Visual feedback SDK for Yaver — shake-to-report, screen recording, voice annotations for vibe coding",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
8
8
  "src/",
9
+ "ios/",
10
+ "android/",
9
11
  "app.plugin.js"
10
12
  ],
11
13
  "license": "MIT",
package/src/BlackBox.ts CHANGED
@@ -49,6 +49,15 @@ export interface BlackBoxConfig {
49
49
  * - Use `BlackBox.wrapConsole()` to intercept console.log/warn/error
50
50
  * (only if you explicitly opt in — no auto-hooking)
51
51
  */
52
+ /** Command received from the agent via the SSE command channel. */
53
+ export interface BlackBoxCommand {
54
+ command: string; // "reload", "reload_bundle"
55
+ data?: Record<string, unknown>; // e.g. { bundleUrl: "/dev/native-bundle" }
56
+ }
57
+
58
+ /** Callback type for handling agent commands. */
59
+ export type CommandHandler = (cmd: BlackBoxCommand) => void;
60
+
52
61
  export class BlackBox {
53
62
  private static baseUrl: string | null = null;
54
63
  private static authToken: string | null = null;
@@ -65,6 +74,12 @@ export class BlackBox {
65
74
  error: typeof console.error;
66
75
  } | null = null;
67
76
 
77
+ // SSE command channel — persistent connection to /blackbox/stream
78
+ private static sseAbortController: AbortController | null = null;
79
+ private static sseReconnectTimer: ReturnType<typeof setTimeout> | null = null;
80
+ private static commandHandlers: CommandHandler[] = [];
81
+ private static sseConnected = false;
82
+
68
83
  /**
69
84
  * Start the black box stream. Call after `YaverFeedback.init()`.
70
85
  *
@@ -97,6 +112,9 @@ export class BlackBox {
97
112
  message: 'Black box streaming started',
98
113
  timestamp: Date.now(),
99
114
  });
115
+
116
+ // Connect SSE command channel for receiving agent commands (reload, etc.)
117
+ BlackBox.connectSSE();
100
118
  }
101
119
 
102
120
  /** Stop the black box stream and flush remaining events. */
@@ -112,6 +130,7 @@ export class BlackBox {
112
130
  clearInterval(BlackBox.flushTimer);
113
131
  BlackBox.flushTimer = null;
114
132
  }
133
+ BlackBox.disconnectSSE();
115
134
  BlackBox.started = false;
116
135
  }
117
136
 
@@ -273,6 +292,137 @@ export class BlackBox {
273
292
  };
274
293
  }
275
294
 
295
+ // ─── Command channel (agent → SDK) ──────────────────────────────
296
+
297
+ /**
298
+ * Register a handler for commands pushed by the agent.
299
+ * The primary use case is receiving "reload" commands when the vibe coder
300
+ * triggers a reload from the Yaver mobile app.
301
+ *
302
+ * @example
303
+ * BlackBox.onCommand((cmd) => {
304
+ * if (cmd.command === 'reload') {
305
+ * DevSettings.reload(); // or Updates.reloadAsync()
306
+ * }
307
+ * });
308
+ */
309
+ static onCommand(handler: CommandHandler): () => void {
310
+ BlackBox.commandHandlers.push(handler);
311
+ // Return unsubscribe function
312
+ return () => {
313
+ BlackBox.commandHandlers = BlackBox.commandHandlers.filter(h => h !== handler);
314
+ };
315
+ }
316
+
317
+ /** Whether the SSE command channel is connected. */
318
+ static get isCommandChannelConnected(): boolean {
319
+ return BlackBox.sseConnected;
320
+ }
321
+
322
+ /**
323
+ * Connect to the agent's /blackbox/stream SSE endpoint.
324
+ * This persistent connection allows the agent to push commands (reload, etc.)
325
+ * back to the SDK. Events are still sent via batch POST /blackbox/events.
326
+ */
327
+ private static async connectSSE(): Promise<void> {
328
+ if (!BlackBox.baseUrl || !BlackBox.authToken) return;
329
+
330
+ // Disconnect any existing connection
331
+ BlackBox.disconnectSSE();
332
+
333
+ const controller = new AbortController();
334
+ BlackBox.sseAbortController = controller;
335
+
336
+ const url = `${BlackBox.baseUrl}/blackbox/command-stream?device=${encodeURIComponent(BlackBox.deviceId)}`;
337
+
338
+ try {
339
+ const response = await fetch(url, {
340
+ method: 'GET',
341
+ headers: {
342
+ Authorization: `Bearer ${BlackBox.authToken}`,
343
+ 'X-Device-ID': BlackBox.deviceId,
344
+ 'X-Platform': Platform.OS,
345
+ 'X-App-Name': BlackBox.appName,
346
+ Accept: 'text/event-stream',
347
+ },
348
+ // @ts-ignore — React Native supports signal on fetch
349
+ signal: controller.signal,
350
+ });
351
+
352
+ if (!response.ok || !response.body) {
353
+ BlackBox.scheduleSSEReconnect();
354
+ return;
355
+ }
356
+
357
+ BlackBox.sseConnected = true;
358
+
359
+ // Read SSE stream
360
+ const reader = response.body.getReader();
361
+ const decoder = new TextDecoder();
362
+ let buffer = '';
363
+
364
+ while (true) {
365
+ const { done, value } = await reader.read();
366
+ if (done) break;
367
+
368
+ buffer += decoder.decode(value, { stream: true });
369
+ const lines = buffer.split('\n');
370
+ buffer = lines.pop() ?? '';
371
+
372
+ for (const line of lines) {
373
+ if (!line.startsWith('data: ')) continue;
374
+ try {
375
+ const msg = JSON.parse(line.slice(6));
376
+ if (msg.type === 'command' && msg.command) {
377
+ const cmd: BlackBoxCommand = msg.command;
378
+ for (const handler of BlackBox.commandHandlers) {
379
+ try {
380
+ handler(cmd);
381
+ } catch {
382
+ // Handler error — don't break the loop
383
+ }
384
+ }
385
+ }
386
+ } catch {
387
+ // Malformed SSE data — skip
388
+ }
389
+ }
390
+ }
391
+ } catch (err: unknown) {
392
+ // AbortError is expected on disconnect
393
+ if (err instanceof Error && err.name === 'AbortError') return;
394
+ } finally {
395
+ BlackBox.sseConnected = false;
396
+ }
397
+
398
+ // Reconnect if still running
399
+ if (BlackBox.started) {
400
+ BlackBox.scheduleSSEReconnect();
401
+ }
402
+ }
403
+
404
+ private static disconnectSSE(): void {
405
+ if (BlackBox.sseAbortController) {
406
+ BlackBox.sseAbortController.abort();
407
+ BlackBox.sseAbortController = null;
408
+ }
409
+ if (BlackBox.sseReconnectTimer) {
410
+ clearTimeout(BlackBox.sseReconnectTimer);
411
+ BlackBox.sseReconnectTimer = null;
412
+ }
413
+ BlackBox.sseConnected = false;
414
+ }
415
+
416
+ private static scheduleSSEReconnect(): void {
417
+ if (!BlackBox.started) return;
418
+ if (BlackBox.sseReconnectTimer) return;
419
+ // Reconnect after 5s
420
+ BlackBox.sseReconnectTimer = setTimeout(() => {
421
+ BlackBox.sseReconnectTimer = null;
422
+ if (BlackBox.started) BlackBox.connectSSE();
423
+ }, 5000);
424
+ }
425
+
276
426
  // ─── Internal ────────────────────────────────────────────────────
277
427
 
278
428
  private static push(event: BlackBoxEvent): void {