viveworker 0.8.1 → 0.8.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.
@@ -84,6 +84,10 @@ const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
84
84
  const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000;
85
85
  const MAX_PRE_CONNECT_BACKOFF_MS = 4_000;
86
86
  const RELAY_RESET_RECONNECT_MS = 250;
87
+ const DEFAULT_FAILURE_WINDOW_MS = 60_000;
88
+ const DEFAULT_FAILURE_THRESHOLD = 4;
89
+ const DEFAULT_CIRCUIT_BREAKER_MS = 60_000;
90
+ const DEFAULT_MAX_CIRCUIT_BREAKER_MS = 10 * 60_000;
87
91
  const DEFAULT_PROLOGUE = new TextEncoder().encode("viveworker/remote-pairing/v1");
88
92
 
89
93
  // CloseEvent codes we emit. 1000 is normal; 4xxx is application-defined.
@@ -113,6 +117,10 @@ const CLOSE_RELAY_RESET_SESSION = 4004;
113
117
  * @property {number} [pingIntervalMs]
114
118
  * @property {number[]} [backoffMs]
115
119
  * @property {number} [handshakeTimeoutMs]
120
+ * @property {number} [failureWindowMs]
121
+ * @property {number} [failureThreshold]
122
+ * @property {number} [circuitBreakerMs]
123
+ * @property {number} [maxCircuitBreakerMs]
116
124
  * @property {typeof WebSocket} [WebSocketImpl] injectable for Node-side tests
117
125
  * @property {{debug?: Function, warn?: Function}} [logger]
118
126
  */
@@ -154,6 +162,10 @@ export class RemotePairingTransport {
154
162
  this._backoffMs = (opts.backoffMs ?? DEFAULT_BACKOFF_MS).slice();
155
163
  if (this._backoffMs.length === 0) this._backoffMs = [1_000];
156
164
  this._handshakeTimeoutMs = opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
165
+ this._failureWindowMs = opts.failureWindowMs ?? DEFAULT_FAILURE_WINDOW_MS;
166
+ this._failureThreshold = opts.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
167
+ this._circuitBreakerMs = opts.circuitBreakerMs ?? DEFAULT_CIRCUIT_BREAKER_MS;
168
+ this._maxCircuitBreakerMs = opts.maxCircuitBreakerMs ?? DEFAULT_MAX_CIRCUIT_BREAKER_MS;
157
169
 
158
170
  this._WebSocketImpl = opts.WebSocketImpl ?? globalThis.WebSocket;
159
171
  if (typeof this._WebSocketImpl !== "function") {
@@ -188,6 +200,12 @@ export class RemotePairingTransport {
188
200
  this._pingTimer = null;
189
201
  /** @type {ReturnType<typeof setTimeout> | null} */
190
202
  this._handshakeTimer = null;
203
+ /** @type {number[]} recent reconnect-triggering failures. */
204
+ this._recentFailureAtMs = [];
205
+ /** If > Date.now(), reconnect attempts are intentionally delayed. */
206
+ this._circuitOpenUntilMs = 0;
207
+ /** Consecutive circuit openings, used to lengthen persistent outages. */
208
+ this._circuitOpenCount = 0;
191
209
 
192
210
  // ---- crypto state (persists across reconnects until RESUME_FAIL) ----
193
211
  /** @type {import("../remote-pairing.bundle.js").NoiseSession | null} */
@@ -320,6 +338,12 @@ export class RemotePairingTransport {
320
338
 
321
339
  _open() {
322
340
  this._cancelReconnectTimer();
341
+ const circuitDelay = this._circuitDelayMs();
342
+ if (circuitDelay > 0) {
343
+ this._log.warn?.(`relay reconnect circuit open for ${circuitDelay}ms`);
344
+ this._scheduleReconnect();
345
+ return;
346
+ }
323
347
  this._setState(STATE.OPENING);
324
348
 
325
349
  const url =
@@ -484,6 +508,12 @@ export class RemotePairingTransport {
484
508
 
485
509
  this._log.debug?.(`ws closed code=${evt?.code} reason=${evt?.reason}`);
486
510
  this._setState(STATE.DISCONNECTED, { code: evt?.code, reason: evt?.reason });
511
+ if (Number(evt?.code) !== CLOSE_NORMAL) {
512
+ this._recordReconnectFailure({
513
+ code: Number(evt?.code) || 0,
514
+ reason: String(evt?.reason || ""),
515
+ });
516
+ }
487
517
  if (isRelayReset) {
488
518
  this._scheduleRelayResetReconnect();
489
519
  } else {
@@ -619,9 +649,10 @@ export class RemotePairingTransport {
619
649
  const idx = Math.min(this._reconnectAttempt, this._backoffMs.length - 1);
620
650
  const waitingForFirstConnect = this._connectPromise != null && this._session == null;
621
651
  const rawDelay = this._backoffMs[idx];
622
- const delay = waitingForFirstConnect
652
+ const backoffDelay = waitingForFirstConnect
623
653
  ? Math.min(rawDelay, MAX_PRE_CONNECT_BACKOFF_MS)
624
654
  : rawDelay;
655
+ const delay = Math.max(backoffDelay, this._circuitDelayMs());
625
656
  this._reconnectAttempt += 1;
626
657
  this._log.debug?.(`reconnect in ${delay}ms (attempt ${this._reconnectAttempt})`);
627
658
  this._reconnectTimer = setTimeout(() => {
@@ -636,7 +667,7 @@ export class RemotePairingTransport {
636
667
  // rendezvous again. Treat it as a protocol reset, not a network failure,
637
668
  // so app boot does not sit behind the exponential backoff ladder.
638
669
  this._reconnectAttempt = 0;
639
- const delay = RELAY_RESET_RECONNECT_MS;
670
+ const delay = Math.max(RELAY_RESET_RECONNECT_MS, this._circuitDelayMs());
640
671
  this._log.debug?.(`reconnect in ${delay}ms (relay reset)`);
641
672
  this._reconnectTimer = setTimeout(() => {
642
673
  this._reconnectTimer = null;
@@ -713,6 +744,9 @@ export class RemotePairingTransport {
713
744
  this._state = newState;
714
745
  if (newState === STATE.CONNECTED) {
715
746
  this._reconnectAttempt = 0;
747
+ this._recentFailureAtMs = [];
748
+ this._circuitOpenUntilMs = 0;
749
+ this._circuitOpenCount = 0;
716
750
  }
717
751
  try {
718
752
  this._onStateChange(newState, prev, info);
@@ -729,6 +763,10 @@ export class RemotePairingTransport {
729
763
  }
730
764
 
731
765
  _fail(err) {
766
+ this._recordReconnectFailure({
767
+ code: CLOSE_FATAL,
768
+ reason: err?.message || "fatal-error",
769
+ });
732
770
  this._setState(STATE.FAILED, { error: err });
733
771
  this._stopPing();
734
772
  this._stopHandshakeTimer();
@@ -748,6 +786,33 @@ export class RemotePairingTransport {
748
786
  this._session = null;
749
787
  this._handshake = null;
750
788
  }
789
+
790
+ _recordReconnectFailure(info = {}) {
791
+ const now = Date.now();
792
+ const windowMs = Math.max(1_000, Number(this._failureWindowMs) || DEFAULT_FAILURE_WINDOW_MS);
793
+ this._recentFailureAtMs = this._recentFailureAtMs.filter((at) => now - at <= windowMs);
794
+ this._recentFailureAtMs.push(now);
795
+ const threshold = Math.max(2, Math.floor(Number(this._failureThreshold) || DEFAULT_FAILURE_THRESHOLD));
796
+ if (this._recentFailureAtMs.length < threshold) return;
797
+
798
+ const baseCooldownMs = Math.max(1_000, Number(this._circuitBreakerMs) || DEFAULT_CIRCUIT_BREAKER_MS);
799
+ const maxCooldownMs = Math.max(baseCooldownMs, Number(this._maxCircuitBreakerMs) || DEFAULT_MAX_CIRCUIT_BREAKER_MS);
800
+ const multiplier = Math.min(16, 2 ** this._circuitOpenCount);
801
+ const cooldownMs = Math.min(maxCooldownMs, baseCooldownMs * multiplier);
802
+ this._circuitOpenCount += 1;
803
+ this._circuitOpenUntilMs = Math.max(this._circuitOpenUntilMs, now + cooldownMs);
804
+ this._recentFailureAtMs = [];
805
+ this._dropNoiseState();
806
+ this._log.warn?.(
807
+ `relay reconnect circuit opened for ${cooldownMs}ms` +
808
+ `${info?.code ? ` after close=${info.code}` : ""}` +
809
+ `${info?.reason ? ` (${String(info.reason).slice(0, 64)})` : ""}`,
810
+ );
811
+ }
812
+
813
+ _circuitDelayMs() {
814
+ return Math.max(0, this._circuitOpenUntilMs - Date.now());
815
+ }
751
816
  }
752
817
 
753
818
  // ---------------------------------------------------------------------------
package/web/sw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const APP_BUILD_ID = "__VIVEWORKER_APP_BUILD_ID__";
2
2
  const CACHE_NAME = `viveworker-${APP_BUILD_ID}`;
3
3
  const APP_SCRIPT_URL = `/app.js?v=${APP_BUILD_ID}`;
4
+ const APP_STYLE_URL = `/app.css?v=${APP_BUILD_ID}`;
4
5
  const API_ROUTER_URL = `/remote-pairing/api-router.js?v=${APP_BUILD_ID}`;
5
6
  const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
6
7
  const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
@@ -14,7 +15,7 @@ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
14
15
  const APP_ASSETS = [
15
16
  "/app",
16
17
  APP_SCRIPT_URL,
17
- "/app.css",
18
+ APP_STYLE_URL,
18
19
  "/app.js",
19
20
  "/i18n.js",
20
21
  "/icons/viveworker-v-pulse.svg",
@@ -31,10 +32,12 @@ const APP_ROUTES = new Set(["/", "/app", "/app/"]);
31
32
  const CACHED_PATHS = new Set(APP_ASSETS.map((asset) => new URL(asset, self.location.origin).pathname));
32
33
  const VERSIONED_CACHE_PATHS = new Set([
33
34
  "/app.js",
35
+ "/app.css",
34
36
  "/remote-pairing/api-router.js",
35
37
  ]);
36
38
  const NETWORK_FIRST_PATHS = new Set([
37
39
  "/app.js",
40
+ "/app.css",
38
41
  "/remote-pairing/api-router.js",
39
42
  ]);
40
43
  const APP_NAVIGATION_NETWORK_TIMEOUT_MS = 1800;
@@ -49,7 +52,7 @@ const APP_SHELL_FALLBACK_HTML = `<!doctype html>
49
52
  <link rel="manifest" href="/manifest.webmanifest">
50
53
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
51
54
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
52
- <link rel="stylesheet" href="/app.css">
55
+ <link rel="stylesheet" href="${APP_STYLE_URL}">
53
56
  <style>
54
57
  html, body { min-height: 100%; margin: 0; background: #081015; color: #f5fbff; }
55
58
  .boot-splash { position: fixed; inset: 0; display: grid; place-items: center; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; background: radial-gradient(circle at 50% 18%, rgba(47, 143, 103, 0.22), transparent 30%), linear-gradient(180deg, #081015 0%, #091015 100%); }
@@ -150,14 +153,14 @@ self.addEventListener("push", (event) => {
150
153
  } catch {
151
154
  payload = {
152
155
  title: "viveworker",
153
- body: event.data ? event.data.text() : "A new Codex item is available.",
156
+ body: event.data ? event.data.text() : fallbackNotificationBody(),
154
157
  data: { url: "/app" },
155
158
  };
156
159
  }
157
160
 
158
161
  const title = payload.title || "viveworker";
159
162
  const options = {
160
- body: payload.body || "A new Codex item is available.",
163
+ body: payload.body || fallbackNotificationBody(),
161
164
  tag: payload.tag || "",
162
165
  data: payload.data || { url: "/app" },
163
166
  };
@@ -175,6 +178,13 @@ self.addEventListener("push", (event) => {
175
178
  })());
176
179
  });
177
180
 
181
+ function fallbackNotificationBody() {
182
+ const language = String(self.navigator?.language || "").toLowerCase();
183
+ return language.startsWith("ja")
184
+ ? "viveworker を開いて確認してください。"
185
+ : "Open viveworker for details.";
186
+ }
187
+
178
188
  self.addEventListener("notificationclick", (event) => {
179
189
  event.preventDefault?.();
180
190
  event.notification.close();