LCOV - code coverage report
Current view: top level - pkg/lib/cockpit/_internal - transport.ts Coverage Total Hit
Test: cockpit Lines: 100.0 % 242 242
Test Date: 2026-06-25 09:20:42

            Line data    Source code
       1          754 : // SPDX-License-Identifier: LGPL-2.1-or-later
       2              : import { EventEmitter } from '../event';
       3              : 
       4              : import type { JsonObject } from './common';
       5              : import { calculate_application, calculate_url } from './location-utils';
       6              : import { ParentWebSocket } from './parentwebsocket';
       7              : 
       8              : type ControlCallback = (message: JsonObject) => void;
       9              : type MessageCallback = (data: string | Uint8Array) => void;
      10              : type FilterCallback = (message: string | ArrayBuffer, channel: string | null, control: JsonObject | null) => boolean;
      11              : 
      12          367 : class TransportGlobals {
      13          367 :     default_transport: Transport | null = null;
      14          367 :     reload_after_disconnect = false;
      15          367 :     expect_disconnect = false;
      16          367 :     init_callback: ControlCallback | null = null;
      17          367 :     default_host: string | null = null;
      18          367 :     process_hints: ControlCallback | null = null;
      19          367 :     incoming_filters: FilterCallback[] = [];
      20          754 : }
      21              : 
      22              : // the transport globals must be a *real* global, across bundles -- i.e. a <script>ed cockpit.js and bundled
      23              : // channel.ts must share the same instance, to avoid initializing transports (and thus initing) twice
      24              : 
      25              : declare global {
      26              :     interface Document {
      27              :         cockpit_transport_globals?: TransportGlobals;
      28              :     }
      29              : }
      30              : 
      31          754 : if (!document.cockpit_transport_globals)
      32          437 :     document.cockpit_transport_globals = new TransportGlobals();
      33              : 
      34          754 : export const transport_globals = document.cockpit_transport_globals;
      35              : 
      36            6 : window.addEventListener('beforeunload', () => {
      37            6 :     transport_globals.expect_disconnect = true;
      38            6 : }, false);
      39              : 
      40          398 : function transport_debug(...args: unknown[]) {
      41          100 :     if (window.debugging == "all" || window.debugging?.includes("channel"))
      42           97 :         console.debug(...args);
      43          398 : }
      44              : 
      45              : /* Private Transport class */
      46          367 : class Transport extends EventEmitter<{ ready(): void }> {
      47              :     application: string;
      48              :     ready: boolean;
      49              : 
      50          367 :     #last_channel = 0;
      51          367 :     #channel_seed = "";
      52          367 :     #ws: WebSocket | ParentWebSocket | null;
      53          367 :     #ignore_health_check = false;
      54          367 :     #got_message = false;
      55          367 :     #check_health_timer;
      56          367 :     #control_cbs: Record<string, ControlCallback> = {};
      57          367 :     #message_cbs: Record<string, MessageCallback> = {};
      58          367 :     #waiting_for_init = true;
      59              : 
      60          367 :     constructor() {
      61          367 :         super();
      62              : 
      63          367 :         this.application = calculate_application();
      64              : 
      65          367 :         if (window.mock)
      66           66 :             window.mock.last_transport = this;
      67              : 
      68              :         /* See if we should communicate via parent */
      69          360 :         if (window.parent !== window && window.name.indexOf("cockpit1:") === 0) {
      70          360 :             this.#ws = new ParentWebSocket(window.parent);
      71          340 :         } else {
      72          347 :             const ws_loc = calculate_url();
      73          347 :             transport_debug("connecting to " + ws_loc);
      74          347 :             this.#ws = new WebSocket(ws_loc, "cockpit1");
      75              : 
      76           49 :             this.#check_health_timer = window.setInterval(() => {
      77           49 :                 if (this.ready && this.#ws)
      78           49 :                     this.#ws.send("\n{ \"command\": \"ping\" }");
      79            1 :                 if (!this.#got_message) {
      80            1 :                     if (this.#ignore_health_check) {
      81            1 :                         console.log("health check failure ignored");
      82            1 :                     } else {
      83            1 :                         console.log("health check failed");
      84            1 :                         this.close({ problem: "timeout" });
      85            1 :                     }
      86            1 :                 }
      87           49 :                 this.#got_message = false;
      88           49 :             }, 30000);
      89          347 :         }
      90              : 
      91          367 :         this.ready = false;
      92              : 
      93          364 :         this.#ws.onopen = () => {
      94          364 :             if (this.#ws) {
      95          364 :                 if (typeof this.#ws.binaryType !== "undefined")
      96          364 :                     this.#ws.binaryType = "arraybuffer";
      97          364 :                 this.#ws.send("\n{ \"command\": \"init\", \"version\": 1 }");
      98          364 :             }
      99          364 :         };
     100              : 
     101           81 :         this.#ws.onclose = () => {
     102           81 :             transport_debug("WebSocket onclose");
     103           81 :             this.#ws = null;
     104           38 :             if (transport_globals.reload_after_disconnect) {
     105           38 :                 transport_globals.expect_disconnect = true;
     106              :                 // @ts-expect-error force-reload parameter is Firefox-only
     107           38 :                 window.location.reload(true);
     108           38 :             }
     109           81 :             this.close();
     110           81 :         };
     111              : 
     112          364 :         this.#ws.onmessage = event => this.dispatch_data(event);
     113          367 :     }
     114              : 
     115              :     /* Called when ready for channels to interact */
     116          367 :     #ready_for_channels() {
     117          367 :         if (!this.ready) {
     118          367 :             this.ready = true;
     119          367 :             this.emit("ready");
     120          367 :         }
     121          367 :     }
     122              : 
     123          364 :     #process_init(options: JsonObject) {
     124           66 :         if (options.problem) {
     125           66 :             this.close({ problem: options.problem });
     126           66 :             return;
     127           66 :         }
     128              : 
     129           66 :         if (options.version !== 1) {
     130           66 :             console.error("received unsupported version in init message: " + options.version);
     131           66 :             this.close({ problem: "not-supported" });
     132           66 :             return;
     133           66 :         }
     134              : 
     135          364 :         if (options["channel-seed"])
     136          364 :             this.#channel_seed = String(options["channel-seed"]);
     137          364 :         if (typeof options.host === 'string')
     138          364 :             transport_globals.default_host = options.host;
     139              : 
     140          364 :         if (transport_globals.init_callback)
     141          364 :             transport_globals.init_callback(options);
     142              : 
     143          364 :         if (this.#waiting_for_init) {
     144          364 :             this.#waiting_for_init = false;
     145          364 :             this.#ready_for_channels();
     146          364 :         }
     147          364 :     }
     148              : 
     149          364 :     #process_control(data: JsonObject) {
     150          364 :         const channel = data.channel;
     151              : 
     152              :         /* Init message received */
     153          364 :         if (data.command == "init") {
     154          364 :             this.#process_init(data);
     155           66 :         } else if (this.#waiting_for_init) {
     156           66 :             this.#waiting_for_init = false;
     157           66 :             if (data.command != "close" || channel) {
     158           66 :                 console.error("received message before init: ", data.command);
     159           66 :                 data = { problem: "protocol-error" };
     160           66 :             }
     161           66 :             this.close(data);
     162              : 
     163              :             /* Any pings get sent back as pongs */
     164           66 :         } else if (data.command == "ping") {
     165          335 :             data.command = "pong";
     166          335 :             this.send_control(data);
     167          109 :         } else if (data.command == "pong") {
     168              :             /* Any pong commands are ignored */
     169          109 :         } else if (data.command == "hint") {
     170          360 :             if (transport_globals.process_hints)
     171          360 :                 transport_globals.process_hints(data);
     172          360 :         } else if (typeof channel === 'string') {
     173          364 :             const func = this.#control_cbs[channel];
     174          364 :             if (func)
     175          364 :                 func(data);
     176          364 :         }
     177          364 :     }
     178              : 
     179          364 :     #process_message(channel: string, payload: string | Uint8Array) {
     180          364 :         const func = this.#message_cbs[channel];
     181          364 :         if (func)
     182          364 :             func(payload);
     183          364 :     }
     184              : 
     185          364 :     dispatch_data(arg: MessageEvent<string | ArrayBuffer>): boolean {
     186          364 :         this.#got_message = true;
     187              : 
     188          364 :         const message = arg.data;
     189          364 :         let channel;
     190          364 :         let control: JsonObject | null = null;
     191          364 :         let payload: string | Uint8Array | null = null;
     192              : 
     193           74 :         if (message instanceof ArrayBuffer) {
     194              :             /* Binary message */
     195           74 :             const frame = new window.Uint8Array(message);
     196           74 :             const nl = frame.indexOf(10);
     197              : 
     198           74 :             channel = new TextDecoder().decode(frame.subarray(0, nl));
     199           66 :             if (!channel) {
     200           66 :                 console.warn('Received invalid binary message without a channel');
     201           66 :                 return false;
     202           66 :             }
     203              : 
     204           74 :             payload = frame.subarray(nl + 1);
     205           74 :             transport_debug("recv binary message:", control, payload);
     206           74 :         } else {
     207          364 :             const nl = message.indexOf('\n');
     208          364 :             channel = message.substring(0, nl);
     209          364 :             if (nl == 0) {
     210          364 :                 control = JSON.parse(message);
     211          364 :                 transport_debug("recv control:", control);
     212          364 :             } else {
     213          364 :                 payload = message.substring(nl + 1);
     214          364 :                 transport_debug("recv text message:", channel, payload);
     215          364 :             }
     216          364 :         }
     217              : 
     218          364 :         for (const filter of transport_globals.incoming_filters)
     219          340 :             if (filter(message, channel, control) === false)
     220          340 :                 return false;
     221              : 
     222          364 :         if (control)
     223          364 :             this.#process_control(control);
     224          364 :         else if (channel && payload)
     225          364 :             this.#process_message(channel, payload);
     226              : 
     227          364 :         return true;
     228          364 :     }
     229              : 
     230           81 :     close(options?: JsonObject): void {
     231           81 :         if (!options)
     232           81 :             options = { problem: "disconnected" };
     233           81 :         options.command = "close";
     234           81 :         window.clearInterval(this.#check_health_timer);
     235           81 :         const ows = this.#ws;
     236           81 :         this.#ws = null;
     237           81 :         if (ows)
     238           75 :             ows.close();
     239           81 :         if (transport_globals.expect_disconnect)
     240           81 :             return;
     241           54 :         this.#ready_for_channels(); /* ready to fail */
     242              : 
     243              :         /* Broadcast to everyone */
     244           54 :         for (const chan in this.#control_cbs)
     245           54 :             this.#control_cbs[chan].apply(null, [options]);
     246           81 :     }
     247              : 
     248          367 :     next_channel(): string {
     249          367 :         this.#last_channel++;
     250          367 :         return this.#channel_seed + String(this.#last_channel);
     251          367 :     }
     252              : 
     253          387 :     send_data(data: string | ArrayBuffer): boolean {
     254           90 :         if (!this.#ws) {
     255           90 :             return false;
     256           90 :         }
     257          384 :         this.#ws.send(data);
     258          384 :         return true;
     259          387 :     }
     260              : 
     261          387 :     send_message(payload: string | ArrayBuffer | Uint8Array, channel: string): boolean {
     262          387 :         if (channel)
     263          387 :             transport_debug("send " + channel, payload);
     264              : 
     265              :         else
     266          387 :             transport_debug("send control:", payload);
     267              : 
     268           88 :         if (typeof payload !== 'string') {
     269              :             /* A binary message */
     270           86 :             const body = payload instanceof ArrayBuffer ? new Uint8Array(payload) : payload;
     271              : 
     272              :             // We want to create channel + '\n' + body in binary
     273           88 :             const header = new TextEncoder().encode(`${channel}\n`);
     274           88 :             const output = new Uint8Array(header.length + body.length);
     275           88 :             output.set(header);
     276           88 :             output.set(body, header.length);
     277           88 :             return this.send_data(output.buffer);
     278           88 :         } else {
     279              :             /* A string message */
     280          387 :             return this.send_data(channel.toString() + "\n" + payload);
     281          387 :         }
     282          387 :     }
     283              : 
     284          396 :     send_control(data: JsonObject): boolean {
     285           98 :         if (!this.#ws && (data.command == "close" || data.command == "kill"))
     286           95 :             return false; /* don't complain if closed and closing */
     287          396 :         if (this.#check_health_timer &&
     288          100 :             data.command == "hint" && data.hint == "ignore_transport_health_check") {
     289              :             /* This is for us, process it directly. */
     290          100 :             this.#ignore_health_check = !!data.data;
     291          100 :             return false;
     292          100 :         }
     293          396 :         return this.send_message(JSON.stringify(data), "");
     294          396 :     }
     295              : 
     296          367 :     register(channel: string, control_cb: ControlCallback, message_cb: MessageCallback): void {
     297          367 :         this.#control_cbs[channel] = control_cb;
     298          367 :         this.#message_cbs[channel] = message_cb;
     299          367 :     }
     300              : 
     301          386 :     unregister(channel: string): void {
     302          386 :         delete this.#control_cbs[channel];
     303          386 :         delete this.#message_cbs[channel];
     304          386 :     }
     305          754 : }
     306              : export type { Transport };
     307              : 
     308          760 : export function ensure_transport(callback: (transport: Transport) => void) {
     309          760 :     if (!transport_globals.default_transport)
     310          460 :         transport_globals.default_transport = new Transport();
     311          760 :     const transport = transport_globals.default_transport;
     312          750 :     if (transport.ready) {
     313          750 :         callback(transport);
     314          457 :     } else {
     315          381 :         transport.on("ready", () => {
     316          381 :             callback(transport);
     317          381 :         });
     318          467 :     }
     319          760 : }
     320              : 
     321              : /* Always close the transport explicitly: allows parent windows to track us */
     322          141 : window.addEventListener("unload", () => {
     323          141 :     if (transport_globals.default_transport)
     324          141 :         transport_globals.default_transport.close();
     325          141 : });
        

Generated by: LCOV version 2.0-1