@lingxia/bridge 0.10.0 → 0.11.1

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.
@@ -1,7 +1,7 @@
1
1
  import { BRIDGE_ERROR } from "./types";
2
2
  import { toBridgeError, toNativeError } from "./invocation";
3
3
  import { installNativeComponentCoverageMonitor } from "./nativecomponents/coverage-monitor";
4
- import { BRIDGE_CONFIG, getCommunicationMethod, getPlatformOS, isAndroid, isHarmony, isIOS, isMacOS, isWindows, isDesktop, } from "./runtime-env";
4
+ import { BRIDGE_CONFIG, getCommunicationMethod, getPlatformOS, isAndroid, isHarmony, isIOS, isMacOS, isWindows, isDesktop, isApple, isDevSession, isRunner, } from "./runtime-env";
5
5
  const NATIVE_HANDLER_NAME = "LingXia";
6
6
  const GLOBAL_RECEIVER_NAME = "__LingXiaRecvMessage";
7
7
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -65,7 +65,18 @@ function activateReceiver(receiver) {
65
65
  function isDebugEnabled(flag) {
66
66
  return debugFlags.all || debugFlags[flag];
67
67
  }
68
+ // `log` is the bridge's own protocol/lifecycle trace. Native log capture
69
+ // forwards whatever the page emits to `console`, so the bridge itself decides
70
+ // whether to surface this framework chatter: only in a `lingxia dev` session
71
+ // (or when a debug flag is set). Shipped apps stay quiet, leaving the captured
72
+ // stream to the page's own output plus bridge warnings/errors.
68
73
  function log(...args) {
74
+ if (!isDevSession() &&
75
+ !debugFlags.all &&
76
+ !debugFlags.proto &&
77
+ !debugFlags.data) {
78
+ return;
79
+ }
69
80
  console.log(LOG_PREFIX, ...args);
70
81
  }
71
82
  function warn(...args) {
@@ -112,6 +123,17 @@ let appleDownstreamTask = null;
112
123
  let appleDownstreamAbortController = null;
113
124
  let appleReconnectTimer = null;
114
125
  let appleReconnectDelayMs = APPLE_RECONNECT_BASE_MS;
126
+ let appleDownstreamDisposed = false;
127
+ // Highest transport frame seq processed. Sent as `?from=` on reconnect so the
128
+ // host replays the gap; survives reconnects so a WebKit-replaced stream resumes
129
+ // without losing frames or tearing down the bridge session.
130
+ let appleLastFrameSeq = 0;
131
+ // Liveness tracking: the host heartbeats an idle connection, so a gap longer
132
+ // than a couple of heartbeats means the connection is silently dead and we
133
+ // reconnect proactively instead of waiting for a read error that may never come.
134
+ const APPLE_DOWNSTREAM_STALE_MS = 35000;
135
+ let appleLastFrameAt = 0;
136
+ let appleWatchdogTimer = null;
115
137
  const portInitState = {
116
138
  listenerInstalled: false,
117
139
  promise: null,
@@ -155,6 +177,23 @@ function clearAppleReconnectTimer() {
155
177
  appleReconnectTimer = null;
156
178
  }
157
179
  }
180
+ function disposeAppleDownstream(reason) {
181
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
182
+ return;
183
+ appleDownstreamDisposed = true;
184
+ clearAppleReconnectTimer();
185
+ if (appleWatchdogTimer !== null) {
186
+ clearInterval(appleWatchdogTimer);
187
+ appleWatchdogTimer = null;
188
+ }
189
+ if (typeof document !== "undefined") {
190
+ document.removeEventListener("visibilitychange", handleAppleVisibilityChange);
191
+ }
192
+ appleForcedReconnectPending = false;
193
+ appleDownstreamAbortController?.abort();
194
+ appleDownstreamConnected = false;
195
+ resetHandshakeState(reason, true);
196
+ }
158
197
  function closeActiveChannelsFromTransport(reason) {
159
198
  for (const [id, channel] of Array.from(activeChannels.entries())) {
160
199
  activeChannels.delete(id);
@@ -196,7 +235,7 @@ function processAppleDownstreamBuffer(buffer) {
196
235
  if (!line)
197
236
  continue;
198
237
  try {
199
- handleIncomingMessage(JSON.parse(line));
238
+ handleAppleDownstreamFrame(JSON.parse(line));
200
239
  }
201
240
  catch (e) {
202
241
  warn("Apple downstream parse error:", e, line);
@@ -204,13 +243,49 @@ function processAppleDownstreamBuffer(buffer) {
204
243
  }
205
244
  return remaining;
206
245
  }
246
+ // Unwrap the transport envelope: {"lxff":seq,"m":<message>} carries a business
247
+ // message with its frame seq; {"lxreset":true} means the host cannot replay our
248
+ // resume point and we must re-handshake.
249
+ function handleAppleDownstreamFrame(frame) {
250
+ if (!frame || typeof frame !== "object")
251
+ return;
252
+ // Any frame — data, heartbeat, or reset — proves the connection is alive.
253
+ appleLastFrameAt = Date.now();
254
+ const record = frame;
255
+ if (record.lxshutdown === true) {
256
+ disposeAppleDownstream("Apple downstream closed");
257
+ return;
258
+ }
259
+ if (record.lxhb !== undefined)
260
+ return; // heartbeat: liveness only, nothing to dispatch
261
+ if (record.lxreset === true) {
262
+ appleLastFrameSeq = 0;
263
+ resetHandshakeState("Apple downstream reset", true);
264
+ startHandshake();
265
+ return;
266
+ }
267
+ if (typeof record.lxff === "number") {
268
+ // Replayed frames after a reconnect can repeat the last-seen seq; ignore
269
+ // anything we have already processed so the session is not double-fed.
270
+ if (record.lxff <= appleLastFrameSeq)
271
+ return;
272
+ appleLastFrameSeq = record.lxff;
273
+ handleIncomingMessage(record.m);
274
+ return;
275
+ }
276
+ // Legacy/un-enveloped line (e.g. a bare keepalive); pass through.
277
+ handleIncomingMessage(frame);
278
+ }
207
279
  async function runAppleDownstream() {
208
280
  if (!APPLE_DOWNSTREAM_URL) {
209
281
  throw new Error("Apple downstream URL is not configured");
210
282
  }
211
283
  const controller = new AbortController();
212
284
  appleDownstreamAbortController = controller;
213
- const response = await fetch(APPLE_DOWNSTREAM_URL, {
285
+ // Resume from the last frame we saw so the host replays the gap on reconnect.
286
+ const separator = APPLE_DOWNSTREAM_URL.includes("?") ? "&" : "?";
287
+ const url = `${APPLE_DOWNSTREAM_URL}${separator}from=${appleLastFrameSeq}`;
288
+ const response = await fetch(url, {
214
289
  method: "GET",
215
290
  cache: "no-store",
216
291
  headers: { Accept: "application/x-ndjson" },
@@ -219,11 +294,16 @@ async function runAppleDownstream() {
219
294
  if (!response.ok) {
220
295
  throw new Error(`Apple downstream HTTP ${response.status}`);
221
296
  }
297
+ if (response.headers.get("X-LingXia-Bridge-Shutdown") === "1") {
298
+ disposeAppleDownstream("Apple downstream closed");
299
+ return;
300
+ }
222
301
  if (!response.body) {
223
302
  throw new Error("Apple downstream response body unavailable");
224
303
  }
225
304
  appleDownstreamConnected = true;
226
305
  appleReconnectDelayMs = APPLE_RECONNECT_BASE_MS;
306
+ appleLastFrameAt = Date.now();
227
307
  if (isDebugEnabled("proto"))
228
308
  log("Apple downstream connected");
229
309
  startHandshake();
@@ -242,7 +322,7 @@ async function runAppleDownstream() {
242
322
  const tail = buffered.trim();
243
323
  if (tail) {
244
324
  try {
245
- handleIncomingMessage(JSON.parse(tail));
325
+ handleAppleDownstreamFrame(JSON.parse(tail));
246
326
  }
247
327
  catch (e) {
248
328
  warn("Apple downstream trailing parse error:", e, tail);
@@ -261,13 +341,17 @@ async function runAppleDownstream() {
261
341
  catch { }
262
342
  }
263
343
  }
264
- function scheduleAppleDownstreamReconnect(reason) {
265
- if (!useAppleDownstreamTransport())
344
+ function scheduleAppleDownstreamReconnect(reason, immediate = false) {
345
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
266
346
  return;
267
347
  clearAppleReconnectTimer();
268
- const delay = appleReconnectDelayMs;
269
- appleReconnectDelayMs = Math.min(appleReconnectDelayMs * 2, APPLE_RECONNECT_MAX_MS);
270
- const message = `Apple downstream disconnected, retrying in ${delay}ms: ${reason}`;
348
+ const delay = immediate ? 0 : appleReconnectDelayMs;
349
+ if (!immediate) {
350
+ appleReconnectDelayMs = Math.min(appleReconnectDelayMs * 2, APPLE_RECONNECT_MAX_MS);
351
+ }
352
+ const message = immediate
353
+ ? `Apple downstream completed, reconnecting immediately: ${reason}`
354
+ : `Apple downstream disconnected, retrying in ${delay}ms: ${reason}`;
271
355
  if (delay <= APPLE_RECONNECT_BASE_MS) {
272
356
  log(message);
273
357
  }
@@ -280,12 +364,16 @@ function scheduleAppleDownstreamReconnect(reason) {
280
364
  }, delay);
281
365
  }
282
366
  function ensureAppleDownstream() {
283
- if (!useAppleDownstreamTransport())
367
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
284
368
  return;
285
369
  if (appleDownstreamTask)
286
370
  return;
287
371
  clearAppleReconnectTimer();
372
+ let completedCleanly = false;
288
373
  appleDownstreamTask = runAppleDownstream()
374
+ .then(() => {
375
+ completedCleanly = true;
376
+ })
289
377
  .catch((e) => {
290
378
  if (appleDownstreamAbortController?.signal.aborted)
291
379
  return;
@@ -295,13 +383,72 @@ function ensureAppleDownstream() {
295
383
  const aborted = appleDownstreamAbortController?.signal.aborted ?? false;
296
384
  appleDownstreamTask = null;
297
385
  appleDownstreamAbortController = null;
298
- const wasConnected = appleDownstreamConnected;
299
386
  appleDownstreamConnected = false;
300
- resetHandshakeState("Apple downstream closed", wasConnected);
301
- if (!aborted)
302
- scheduleAppleDownstreamReconnect("stream closed");
387
+ // A dropped transport is not a dead session: reconnect resumes from the
388
+ // last seq and the host replays the gap, so the handshake and in-flight
389
+ // streams stay intact. Only a host-sent reset (handled on the frame path)
390
+ // or a real abort tears things down.
391
+ if (!aborted && !appleDownstreamDisposed) {
392
+ // Native intentionally completes bootstrap/replay responses to flush
393
+ // WebKit. Reconnect without the failure backoff so a live stream can
394
+ // catch up and settle onto a long-lived downstream.
395
+ scheduleAppleDownstreamReconnect("stream closed", completedCleanly);
396
+ }
303
397
  });
304
398
  }
399
+ // Drop the current downstream and reconnect, exactly as WebKit does when it
400
+ // replaces the streaming fetch. The reconnect carries the real `from=<lastSeq>`,
401
+ // so nothing is lost. Driven by the staleness watchdog and the foreground
402
+ // handler, and exposed as a dev hook for repro harnesses. A no-op off Apple.
403
+ let appleForcedReconnectPending = false;
404
+ function forceDownstreamReconnect() {
405
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
406
+ return;
407
+ // Coalesce a burst of triggers (rapid foreground toggles, repeated calls)
408
+ // into a single reconnect so they cannot stack concurrent fetches.
409
+ if (appleForcedReconnectPending)
410
+ return;
411
+ appleForcedReconnectPending = true;
412
+ const task = appleDownstreamTask;
413
+ appleDownstreamAbortController?.abort();
414
+ const reconnect = () => {
415
+ appleForcedReconnectPending = false;
416
+ ensureAppleDownstream();
417
+ };
418
+ if (task)
419
+ task.finally(reconnect);
420
+ else
421
+ reconnect();
422
+ }
423
+ // A silently dead connection (half-open socket) never surfaces a read error, so
424
+ // poll for heartbeat staleness and reconnect when the host has gone quiet.
425
+ function startAppleWatchdog() {
426
+ if (!useAppleDownstreamTransport() ||
427
+ appleDownstreamDisposed ||
428
+ appleWatchdogTimer !== null)
429
+ return;
430
+ appleWatchdogTimer = setInterval(() => {
431
+ // No `connected` guard: a reconnect that hung before delivering a frame
432
+ // also goes stale, and forcing a reconnect aborts and retries it.
433
+ if (appleLastFrameAt === 0)
434
+ return;
435
+ if (Date.now() - appleLastFrameAt > APPLE_DOWNSTREAM_STALE_MS) {
436
+ warn("Apple downstream stale (no heartbeat), forcing reconnect");
437
+ forceDownstreamReconnect();
438
+ }
439
+ }, 5000);
440
+ }
441
+ // WebKit suspends/throttles a backgrounded webview and may tear the stream
442
+ // down; reconnect the moment it returns so the UI is never left stale.
443
+ function handleAppleVisibilityChange() {
444
+ if (document.visibilityState === "visible")
445
+ forceDownstreamReconnect();
446
+ }
447
+ function installAppleForegroundReconnect() {
448
+ if (!useAppleDownstreamTransport() || typeof document === "undefined")
449
+ return;
450
+ document.addEventListener("visibilitychange", handleAppleVisibilityChange);
451
+ }
305
452
  function getMessagePort() {
306
453
  if (messagePort)
307
454
  return Promise.resolve(messagePort);
@@ -380,6 +527,9 @@ function createListenerBuckets() {
380
527
  function createStreamHandle(id, cancelFn) {
381
528
  const listeners = createListenerBuckets();
382
529
  let done = false;
530
+ // Only buffer for the async iterator; long-lived streams consumed via
531
+ // .on("data") would otherwise accumulate payloads forever.
532
+ let iteratorRequested = false;
383
533
  let resolveResult = () => { };
384
534
  let rejectResult = () => { };
385
535
  const pendingData = [];
@@ -401,6 +551,7 @@ function createStreamHandle(id, cancelFn) {
401
551
  return this;
402
552
  },
403
553
  [Symbol.asyncIterator]() {
554
+ iteratorRequested = true;
404
555
  return {
405
556
  next() {
406
557
  if (pendingData.length > 0) {
@@ -429,7 +580,7 @@ function createStreamHandle(id, cancelFn) {
429
580
  if (pendingReads.length > 0) {
430
581
  pendingReads.shift().resolve({ done: false, value: payload });
431
582
  }
432
- else {
583
+ else if (iteratorRequested) {
433
584
  pendingData.push(payload);
434
585
  }
435
586
  for (const listener of listeners.data) {
@@ -483,6 +634,9 @@ function createChannel(id, sendFn, closeFn) {
483
634
  let open = false;
484
635
  let outboundSeq = 0;
485
636
  let closed = false;
637
+ // Only buffer for the async iterator or pre-first-listener replay;
638
+ // otherwise a listener-driven channel would accumulate payloads forever.
639
+ let iteratorRequested = false;
486
640
  const pendingData = [];
487
641
  const pendingReads = [];
488
642
  const channel = {
@@ -523,6 +677,7 @@ function createChannel(id, sendFn, closeFn) {
523
677
  return this;
524
678
  },
525
679
  [Symbol.asyncIterator]() {
680
+ iteratorRequested = true;
526
681
  return {
527
682
  next() {
528
683
  if (pendingData.length > 0) {
@@ -554,7 +709,7 @@ function createChannel(id, sendFn, closeFn) {
554
709
  if (pendingReads.length > 0) {
555
710
  pendingReads.shift().resolve({ done: false, value: payload });
556
711
  }
557
- else {
712
+ else if (iteratorRequested || listeners.data.size === 0) {
558
713
  pendingData.push(payload);
559
714
  }
560
715
  for (const listener of listeners.data) {
@@ -642,7 +797,15 @@ function isTransportReady() {
642
797
  communicationMethod === WEB_MESSAGE_TYPE);
643
798
  }
644
799
  function canSendAppMessages() {
645
- return isTransportReady() && handshakeDone;
800
+ if (!handshakeDone)
801
+ return false;
802
+ // Apple upstream posts through WKScriptMessageHandler independently of the
803
+ // downstream fetch. During a replay reconnect, native retains responses by
804
+ // sequence, so treating that short gap as an unsendable session can strand
805
+ // requests in the outbox with no second `ready` event to flush them.
806
+ if (useAppleDownstreamTransport())
807
+ return true;
808
+ return isTransportReady();
646
809
  }
647
810
  function rejectPendingRequest(reqId, err) {
648
811
  const info = pendingReq.get(reqId);
@@ -1031,7 +1194,15 @@ function handleIncomingMessage(msg) {
1031
1194
  if (pendingReq.has(message.id)) {
1032
1195
  const req = pendingReq.get(message.id);
1033
1196
  if (req?.mode === "stream" && req.stream) {
1034
- armRequestTimer(message.id);
1197
+ // The first frame proves the handler is live. Long-lived streams
1198
+ // (bookmarks.watch, proxy.watch) are idle between pushes, so the
1199
+ // request timeout must not treat silence as death — disarm it for
1200
+ // good instead of re-arming per frame.
1201
+ if (req.timerId !== null) {
1202
+ clearTimeout(req.timerId);
1203
+ req.timerId = null;
1204
+ }
1205
+ req.timeoutMs = 0;
1035
1206
  req.stream._emitData(message.payload);
1036
1207
  }
1037
1208
  else {
@@ -1109,8 +1280,10 @@ function handleIncomingMessage(msg) {
1109
1280
  case "ch.close": {
1110
1281
  const pendingChannel = pendingChannels.get(message.id);
1111
1282
  if (pendingChannel) {
1112
- pendingChannels.delete(message.id);
1113
- pendingChannel.channel._emitClose(message.code, message.reason);
1283
+ rejectPendingChannel(message.id, {
1284
+ code: message.code ?? BRIDGE_ERROR.STREAM_CLOSED,
1285
+ message: message.reason ?? "Channel closed before opening",
1286
+ });
1114
1287
  return;
1115
1288
  }
1116
1289
  const channel = activeChannels.get(message.id);
@@ -1458,6 +1631,8 @@ export const LingXiaBridge = {
1458
1631
  isMacOS,
1459
1632
  isWindows,
1460
1633
  isDesktop,
1634
+ isApple,
1635
+ isRunner,
1461
1636
  getOS: getPlatformOS,
1462
1637
  },
1463
1638
  dom: {
@@ -1598,6 +1773,8 @@ export function initBridge() {
1598
1773
  activateReceiver(LingXiaBridge._receiveEvaluateMessage);
1599
1774
  if (useAppleDownstreamTransport()) {
1600
1775
  ensureAppleDownstream();
1776
+ startAppleWatchdog();
1777
+ installAppleForegroundReconnect();
1601
1778
  }
1602
1779
  else if (communicationMethod === MESSAGE_PORT_TYPE) {
1603
1780
  installMessagePortInitListener();
@@ -1612,6 +1789,9 @@ export function initBridge() {
1612
1789
  warn("Unknown method");
1613
1790
  }
1614
1791
  window.LingXiaBridge = LingXiaBridge;
1792
+ // Dev hook so repro/test pages can simulate a WebKit stream replacement.
1793
+ window.__lxForceDownstreamReconnect =
1794
+ forceDownstreamReconnect;
1615
1795
  installNativeComponentCoverageMonitor({
1616
1796
  os: getPlatformOS(),
1617
1797
  send: sendNativeComponentMessage,
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export { LingXiaBridge, channel, initBridge, invoke, notify, stream, } from './bridge';
9
9
  export { isNativeError } from './invocation';
10
+ export { getDisplayLanguage } from './runtime-env';
10
11
  export { renderErrorUI, hasError, getErrorInfo } from './error';
11
12
  export { boot, bootWhenReady } from './boot';
12
13
  import { bootWhenReady } from './boot';
@@ -1,4 +1,11 @@
1
1
  export const BRIDGE_CONFIG = (typeof window !== 'undefined' && window.__LX_BRIDGE_CFG) || {};
2
+ const displayLanguage = BRIDGE_CONFIG.displayLanguage?.trim() || 'en-US';
3
+ if (typeof document !== 'undefined' && document.documentElement) {
4
+ document.documentElement.lang = displayLanguage;
5
+ }
6
+ export function getDisplayLanguage() {
7
+ return displayLanguage;
8
+ }
2
9
  export function getPlatformOS() {
3
10
  return BRIDGE_CONFIG.os || 'unknown';
4
11
  }
@@ -20,6 +27,24 @@ export function isWindows() {
20
27
  export function isDesktop() {
21
28
  return isMacOS() || isWindows();
22
29
  }
30
+ // iOS and macOS share the WKWebView transport, so features scoped to it (e.g.
31
+ // the streaming downstream) key off this rather than the two OS checks.
32
+ export function isApple() {
33
+ return isIOS() || isMacOS();
34
+ }
35
+ // True when attached to a `lingxia dev` session (the host sets `dev` in
36
+ // `__LX_BRIDGE_CFG`). Used to surface the bridge's own protocol/lifecycle trace
37
+ // only during development.
38
+ export function isDevSession() {
39
+ return BRIDGE_CONFIG.dev === true;
40
+ }
41
+ // True when running inside the LingXia Runner (the `lingxia dev` device
42
+ // simulator), which the host marks in `__LX_BRIDGE_CFG`. Unlike a real host
43
+ // app in dev mode, the Runner lacks host-declared surfaces such as the
44
+ // terminal — apps read this to hide those affordances.
45
+ export function isRunner() {
46
+ return BRIDGE_CONFIG.runner === true;
47
+ }
23
48
  export function getCommunicationMethod() {
24
49
  if (BRIDGE_CONFIG.os === 'iOS' || BRIDGE_CONFIG.os === 'macOS')
25
50
  return 'webkit';
@@ -1,9 +1,9 @@
1
- import { getPlatformOS } from '../runtime-env';
1
+ import { isDesktop } from '../runtime-env';
2
2
  const ANIMATION_MS = 300;
3
3
  const STYLE_ID = 'lx-action-sheet-style';
4
4
  let activeSheet = null;
5
5
  function isDesktopMode() {
6
- return getPlatformOS() === 'macOS';
6
+ return isDesktop();
7
7
  }
8
8
  function ensureActionSheetStyle() {
9
9
  if (document.getElementById(STYLE_ID))