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