PageSourceSearch

https://portal.miira.app/suite-kiosk-health.js

js miira.app collected 2026-10-02 15:59:16 UTC 6,581 bytes, 145 lines download raw bytes

1/**
2 * Kiosk health for an unattended sales-suite display (present_browser.html with `kiosk=1`).
3 *
4 * Two rings of self-healing sit on top of the display's own retries; this module is the policy for
5 * the inner one and the wire format for the outer one:
6 *
7 *  - Inner ring (in the page): the shell tracks heartbeats from present_lite and reloads itself
8 *    when the display never comes up or stops beating.
9 *  - Outer ring (kiosk/Start-MiiraKiosk.ps1 on the PC): the watchdog reads the tab title through
10 *    Edge's remote-debugging `/json` endpoint and relaunches the browser when the title says the
11 *    page is stuck or gone. The title is the only channel a script outside the browser can read
12 *    without driving it, so the shell publishes state there as `miira-kiosk:<state>:<ms>`.
13 *
14 * States the display reports:
15 *   booting   page alive, nothing on screen yet
16 *   live      holding crawl or pushed media is up
17 *   rejected  the suite key was refused — a reload cannot fix that, so neither ring acts on it
18 *
19 * Pure: the clock, the title sink, the reload and the online probe are injected, so
20 * suite-kiosk-health.test.js can pin the timings. Loaded as a classic script like the other
21 * suite-* modules, so it must not assume a module system.
22 */
23(function (scope) {
24  var TITLE_PREFIX = 'miira-kiosk';
25  var TITLE_PATTERN = /^miira-kiosk:(booting|live|rejected):(\d+)$/;
26  var STATES = { booting: true, live: true, rejected: true };
27  var DEFAULTS = {
28    // Nothing on screen this long after the display was mounted: reload.
29    bootTimeoutMs: 3 * 60 * 1000,
30    // No heartbeat this long (they arrive every 15s): the display's script is wedged, reload.
31    staleMs: 2 * 60 * 1000,
32    // Never reload more often than this, so a page broken in a way a reload cannot fix does not
33    // spin; the watchdog on the PC is the outer ring for that case.
34    minReloadGapMs: 5 * 60 * 1000,
35  };
36
37  function formatTitle(state, atMs) {
38    return TITLE_PREFIX + ':' + state + ':' + String(atMs);
39  }
40
41  /** `{ state, at }` for a heartbeat title, or null for anything else (an error page, the default). */
42  function parseTitle(title) {
43    var m = TITLE_PATTERN.exec(String(title || ''));
44    if (!m) return null;
45    return { state: m[1], at: Number(m[2]) };
46  }
47
48  /**
49   * @param {object} options
50   * @param {() => number} [options.now]            clock, ms since epoch
51   * @param {(title: string) => void} [options.setTitle]
52   * @param {(reason: string, atMs: number) => void} [options.reload]
53   * @param {() => boolean} [options.isOnline]       navigator.onLine; a reload with no network only
54   *                                                 trades a stuck page for an error page
55   * @param {number} [options.lastReloadAt]          carried across the reload this module causes
56   */
57  function createKioskHealth(options) {
58    var opts = options || {};
59    var bootTimeoutMs = typeof opts.bootTimeoutMs === 'number' ? opts.bootTimeoutMs : DEFAULTS.bootTimeoutMs;
60    var staleMs = typeof opts.staleMs === 'number' ? opts.staleMs : DEFAULTS.staleMs;
61    var minReloadGapMs = typeof opts.minReloadGapMs === 'number' ? opts.minReloadGapMs : DEFAULTS.minReloadGapMs;
62    var now = typeof opts.now === 'function' ? opts.now : function () { return Date.now(); };
63    var setTitle = typeof opts.setTitle === 'function' ? opts.setTitle : function () {};
64    var reload = typeof opts.reload === 'function' ? opts.reload : function () {};
65    var isOnline = typeof opts.isOnline === 'function' ? opts.isOnline : function () { return true; };
66
67    var state = 'booting';
68    var mountedAt = null;
69    var lastBeatAt = now();
70    var lastReloadAt = Number(opts.lastReloadAt) || 0;
71
72    function publish() {
73      setTitle(formatTitle(state, lastBeatAt));
74    }
75
76    // Publish before anything is mounted. Until this, a shell that was alive but had nothing on
77    // screen yet - retrying a suite the server will not hand over, say - left the tab title at the
78    // page's default, which the watchdog cannot tell apart from a page whose script never ran at
79    // all. It relaunched Edge every four minutes on a browser that was doing exactly what it
80    // should. An empty state in the watchdog log now means the script really did not run.
81    publish();
82
83    function requestReload(reason) {
84      if (!isOnline()) return 'offline:' + reason;
85      var t = now();
86      if (t - lastReloadAt < minReloadGapMs) return 'suppressed:' + reason;
87      lastReloadAt = t;
88      reload(reason, t);
89      return 'reload:' + reason;
90    }
91
92    return {
93      /** The display iframe was (re)mounted: the boot clock starts here. */
94      mounted: function () {
95        mountedAt = now();
96        lastBeatAt = mountedAt;
97        state = 'booting';
98        publish();
99      },
100      /** A heartbeat from the display. Unknown states are ignored so a typo cannot stall the title. */
101      beat: function (next) {
102        if (!STATES[next]) return false;
103        if (mountedAt === null) mountedAt = now();
104        state = next;
105        lastBeatAt = now();
106        publish();
107        return true;
108      },
109      /**
110       * Call on an interval. Returns what it decided — null when there is nothing to do,
111       * `reload:<reason>`, or `suppressed:<reason>` / `offline:<reason>` when a reload was due but
112       * withheld.
113       */
114      tick: function () {
115        // Nothing mounted: the shell is alive and waiting, so keep the title fresh rather than let
116        // it go stale. This is the shell's own liveness - a wedged page stops calling tick() and
117        // the heartbeat ages out, which is the signal the watchdog wants.
118        if (mountedAt === null) {
119          lastBeatAt = now();
120          publish();
121          return null;
122        }
123        if (state === 'rejected') return null;
124        var t = now();
125        if (state === 'booting' && t - mountedAt >= bootTimeoutMs) return requestReload('boot-timeout');
126        if (t - lastBeatAt >= staleMs) return requestReload('stale');
127        return null;
128      },
129      snapshot: function () {
130        return { state: state, mountedAt: mountedAt, lastBeatAt: lastBeatAt, lastReloadAt: lastReloadAt };
131      },
132    };
133  }
134
135  var api = {
136    TITLE_PREFIX: TITLE_PREFIX,
137    DEFAULTS: DEFAULTS,
138    formatTitle: formatTitle,
139    parseTitle: parseTitle,
140    createKioskHealth: createKioskHealth,
141  };
142
143  if (typeof module !== 'undefined' && module.exports) module.exports = api;
144  if (scope) scope.MiiraSuiteKioskHealth = api;
145})(typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : null);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.