-
Notifications
You must be signed in to change notification settings - Fork 12
/
CodeChatEditorFramework.mts
359 lines (332 loc) · 14.4 KB
/
CodeChatEditorFramework.mts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
// Copyright (C) 2023 Bryan A. Jones.
//
// This file is part of the CodeChat Editor. The CodeChat Editor is free
// software: you can redistribute it and/or modify it under the terms of the GNU
// General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// The CodeChat Editor is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with
// the CodeChat Editor. If not, see
// [http://www.gnu.org/licenses](http://www.gnu.org/licenses).
//
// # `CodeChatEditorFramework.mts` -- the CodeChat Editor Client Framework
//
// This maintains a websocket connection between the CodeChat Editor Server. The
// accompanying HTML is a full-screen iframe, allowing the Framework to change
// or update the webpage in response to messages received from the websocket, or
// to report navigation events to as a websocket message when the iframe's
// location changes.
//
// ## Imports
//
// ### JavaScript/TypeScript
//
// #### Third-party
import ReconnectingWebSocket from "./ReconnectingWebSocket.cjs";
// ## Websocket
//
// This code communicates with the CodeChat Editor Server via its websocket
// interface.
//
// ### Message types
//
// These mirror the same definitions in the Rust webserver, so that the two can
// exchange messages.
interface EditorMessage {
id: number;
message: EditorMessageContents;
}
type ResultType = { Ok: "Void" } | { Err: string };
interface EditorMessageContents {
Update?: UpdateMessageContents;
CurrentFile?: string;
Load?: string;
Result?: ResultType;
RequestClose?: null;
}
// The max length of a message to show in the console.
const MAX_MESSAGE_LENGTH = 200;
// The timeout for a websocket `Response`.
const RESPONSE_TIMEOUT = 15000;
// An instance of the websocket communication class.
let webSocketComm: WebSocketComm;
class WebSocketComm {
// Use a unique ID for each websocket message sent. See the Implementation
// section on Message IDs for more information.
ws_id = -9007199254740990;
// The websocket used by this class. Really a `ReconnectingWebSocket`, but
// that's not a type.
ws: WebSocket;
// A map of message id to (timer id, callback) for all pending messages.
pending_messages: Record<
number,
{
timer_id: number;
callback: () => void;
}
> = {};
// True when the iframe is loading, so that an `Update` should be postponed
// until the page load is finished. Otherwise, the page is fully loaded, so
// the `Update` may be applied immediately.
onloading = false;
// The current filename of the file being edited. This is provided by the
// IDE and passed back to it, but not otherwise used by the Framework.
current_filename: string | undefined = undefined;
constructor(ws_url: string) {
// The `ReconnectingWebSocket` doesn't provide ALL the `WebSocket`
// methods. Ignore this, since we can't use `ReconnectingWebSocket` as a
// type.
/// @ts-ignore
this.ws = new ReconnectingWebSocket!(ws_url);
// Identify this client on connection.
this.ws.onopen = () => {
console.log(`CodeChat Client: websocket to CodeChat Server open.`);
};
// Provide logging to help track down errors.
this.ws.onerror = (event: any) => {
console.error(`CodeChat Client: websocket error ${event}.`);
};
this.ws.onclose = (event: any) => {
console.log(
`CodeChat Client: websocket closed by event type ${event.type}: ${event.detail}. This should only happen on shutdown.`,
);
};
// Handle websocket messages.
this.ws.onmessage = (event: any) => {
// Parse the received message, which must be a single element of a
// dictionary representing a `JointMessage`.
const joint_message = JSON.parse(event.data) as EditorMessage;
const { id: id, message: message } = joint_message;
console.log(`Received data id = ${id}, message = ${JSON.stringify(message).substring(0, MAX_MESSAGE_LENGTH)}`);
console.assert(id !== undefined);
console.assert(message !== undefined);
const keys = Object.keys(message);
console.assert(keys.length === 1);
const key = keys[0];
const value = Object.values(message)[0];
// Process this message.
switch (key) {
case "Update":
// Load this data in.
const current_update = value as UpdateMessageContents;
// Check or update the `current_filename`.
if (this.current_filename === undefined) {
this.current_filename = current_update.file_path;
} else if (current_update.file_path !== this.current_filename) {
const msg = `Ignoring update for ${current_update.file_path} because it's not the current file ${this.current_filename}.`;
console.log(msg);
this.send_result(id, msg);
break;
}
let result = null;
const contents = current_update.contents;
if (contents !== null && contents !== undefined) {
// If the page is still loading, wait until the load
// completed before updating the editable contents.
if (this.onloading) {
root_iframe!.onload = () => {
root_iframe!.contentWindow!.CodeChatEditor.open_lp(
contents,
);
this.onloading = false;
};
} else {
root_iframe!.contentWindow!.CodeChatEditor.open_lp(
contents,
);
}
} else {
// TODO: handle scroll/cursor updates.
result = `Unhandled Update message: ${current_update}`;
console.log(result);
}
this.send_result(id, result);
break;
case "CurrentFile":
const current_file = value as string;
// If the page is still loading, then don't save. Otherwise,
// save the editor contents if necessary.
let cce = root_iframe?.contentWindow?.CodeChatEditor;
let promise =
cce !== undefined
? cce.on_save(true)
: Promise.resolve();
promise.then((_) => {
// Now, it's safe to load a new file.
const testSuffix = testMode
? // Append the test parameter correctly, depending if
// there are already parameters or not.
current_file.indexOf("?") === -1
? "?test"
: "&test"
: "";
// Tell the client to allow this navigation -- the
// document it contains has already been saved.
if (cce !== undefined) {
cce.allow_navigation = true;
}
this.set_root_iframe_src(current_file + testSuffix);
// The `current_file` is a URL-encoded path, not a
// filesystem path. So, we can't use it for
// `current_filename`. Instead, signal that the
// `current_filename` should be set on the next `Update`
// message.
this.current_filename = undefined;
this.send_result(id, null);
});
break;
case "Result":
// Cancel the timer for this message and remove it from
// `pending_messages`.
const pending_message = this.pending_messages[id];
if (pending_message !== undefined) {
const { timer_id, callback } =
this.pending_messages[id];
clearTimeout(timer_id);
callback();
delete this.pending_messages[id];
}
// Report if this was an error.
const result_contents = value as ResultType;
if ("Err" in result_contents) {
console.log(
`Error in message ${id}: ${result_contents.Err}.`,
);
}
break;
default:
console.log(
`Received unhandled message ${key}(${JSON.stringify(value).substring(0, MAX_MESSAGE_LENGTH)})`,
);
this.send_result(id, `Unhandled message ${key}(${value})`);
break;
}
};
}
set_root_iframe_src = (url: string) => {
// Set the new src to (re)load content. At startup, the `srcdoc`
// attribute shows some welcome text. Remove it so that we can now
// assign the `src` attribute.
root_iframe!.removeAttribute("srcdoc");
root_iframe!.src = url;
// There's no easy way to determine when the iframe's DOM is ready. This
// is a kludgy workaround -- set a flag.
this.onloading = true;
root_iframe!.onload = () => (this.onloading = false);
};
send = (data: any) => this.ws.send(data);
close = (...args: any) => this.ws.close(...args);
// Report an error from the server.
report_server_timeout = (message_id: number) => {
delete this.pending_messages[message_id];
console.log(`Error: server timeout for message id ${message_id}`);
};
// Send a message expecting a result to the server.
send_message = (
message: EditorMessageContents,
callback: () => void = () => 0,
) => {
const id = this.ws_id;
this.ws_id += 3;
// Add in the current filename to the message, if it's an `Update`.
if (message.Update !== undefined) {
console.assert(this.current_filename !== undefined);
message.Update.file_path = this.current_filename!;
}
console.log(
`Sent message ${id}, ${JSON.stringify(message).substring(0, MAX_MESSAGE_LENGTH)}`,
);
const jm: EditorMessage = {
id: id,
message: message,
};
this.ws.send(JSON.stringify(jm));
this.pending_messages[id] = {
timer_id: window.setTimeout(this.report_server_timeout, RESPONSE_TIMEOUT, id),
callback,
};
};
current_file = (url: URL) => {
// If this points to the Server, then tell the IDE to load a new file.
if (url.host === window.location.host) {
this.send_message({ CurrentFile: url.toString() }, () => {
this.set_root_iframe_src(url.toString());
});
} else {
this.set_root_iframe_src(url.toString());
}
// Read the `current_filename` from the next `Update` message.
this.current_filename = undefined;
};
// Send a result (a response to a message from the server) back to the
// server.
send_result = (id: number, result: string | null = null) => {
const message: EditorMessageContents = {
Result: result === null ? { Ok: "Void" } : { Err: result },
};
console.log(`Sending result id = ${id}, message = ${JSON.stringify(message).substring(0, MAX_MESSAGE_LENGTH)}`);
// We can't simply call `send_message` because that function expects a
// result message back from the server.
const jm: EditorMessage = {
id,
message,
};
this.ws.send(JSON.stringify(jm));
};
}
// The iframe element which composes this page.
let root_iframe: HTMLIFrameElement | undefined;
// True when in test mode.
let testMode = false;
// Load the dynamic content into the static page.
export const page_init = (
// The pathname for the websocket to use. The remainder of the URL is
// derived from the hosting page's URL. See the
// [Location docs](https://developer.mozilla.org/en-US/docs/Web/API/Location)
// for a nice, interactive definition of the components of a URL.
ws_pathname: string,
// Test mode flag
testMode_: boolean,
) => {
testMode = testMode_;
on_dom_content_loaded(async () => {
// If the hosting page uses HTTPS, then use a secure websocket (WSS
// protocol); otherwise, use an insecure websocket (WS).
const protocol = window.location.protocol === "http:" ? "ws:" : "wss:";
// Build a websocket address based on the URL of the current page.
webSocketComm = new WebSocketComm(
`${protocol}//${window.location.host}/${ws_pathname}`,
);
root_iframe = document.getElementById(
"CodeChat-iframe",
)! as HTMLIFrameElement;
window.CodeChatEditorFramework = {
webSocketComm,
};
});
};
// This is copied from
// [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event#checking_whether_loading_is_already_complete).
const on_dom_content_loaded = (on_load_func: () => void) => {
if (document.readyState === "loading") {
// Loading hasn't finished yet.
document.addEventListener("DOMContentLoaded", on_load_func);
} else {
// `DOMContentLoaded` has already fired.
on_load_func();
}
};
// Tell TypeScript about the global namespace this program defines.
declare global {
interface Window {
CodeChatEditorFramework: {
webSocketComm: WebSocketComm;
};
CodeChatEditor_test: any;
}
}