-
Notifications
You must be signed in to change notification settings - Fork 12
/
webserver.rs
1278 lines (1196 loc) · 50.7 KB
/
webserver.rs
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
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// 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).
/// # `webserver.rs` -- Serve CodeChat Editor Client webpages
// ## Submodules
mod filewatcher;
#[cfg(test)]
pub mod tests;
mod vscode;
// ## Imports
//
// ### Standard library
use std::{
collections::{HashMap, HashSet},
env, fs,
path::{self, Path, PathBuf, MAIN_SEPARATOR_STR},
str::FromStr,
sync::{Arc, Mutex},
time::Duration,
};
// ### Third-party
use actix_files;
use actix_web::{
dev::{ServerHandle, ServiceFactory, ServiceRequest},
error::Error,
get,
http::header::ContentType,
web, App, HttpRequest, HttpResponse, HttpServer,
};
use actix_ws::AggregatedMessage;
use bytes::Bytes;
use dunce::simplified;
use futures_util::StreamExt;
use indoc::formatdoc;
use lazy_static::lazy_static;
use log::{error, info, warn, LevelFilter};
use log4rs;
use mime::Mime;
use mime_guess;
use path_slash::{PathBufExt, PathExt};
use serde::{Deserialize, Serialize};
use serde_json;
use tokio::{
fs::File,
io::AsyncReadExt,
select,
sync::{
mpsc::{Receiver, Sender},
oneshot,
},
task::JoinHandle,
time::sleep,
};
use url::Url;
use vscode::{
serve_vscode_fs, vscode_client_framework, vscode_client_websocket, vscode_ide_websocket,
};
// ### Local
use crate::processing::{
source_to_codechat_for_web_string, CodeChatForWeb, TranslationResultsString,
};
use filewatcher::{
filewatcher_browser_endpoint, filewatcher_client_endpoint, filewatcher_root_fs_redirect,
filewatcher_websocket,
};
/// ## Data structures
///
/// ### Data structures supporting a websocket connection between the IDE, this server, and the CodeChat Editor Client
///
/// Provide queues which send data to the IDE and the CodeChat Editor Client.
#[derive(Debug)]
struct WebsocketQueues {
from_websocket_tx: Sender<EditorMessage>,
to_websocket_rx: Receiver<EditorMessage>,
}
#[derive(Debug)]
/// Since an `HttpResponse` doesn't implement `Send`, use this as a simply proxy
/// for it. This is used to send a response to the HTTP task to an HTTP request
/// made to that task. Send: String, response
struct ProcessingTaskHttpRequest {
/// The path of the file requested.
file_path: PathBuf,
/// True if this file is a TOC.
is_toc: bool,
/// True if test mode is enabled.
is_test_mode: bool,
/// A queue to send the response back to the HTTP task.
response_queue: oneshot::Sender<SimpleHttpResponse>,
}
/// Since an `HttpResponse` doesn't implement `Send`, use this as a proxy to
/// cover all responses to serving a file.
#[derive(Debug)]
enum SimpleHttpResponse {
/// Return a 200 with the provided string as the HTML body.
Ok(String),
/// Return an error (400 status code) with the provided string as the HTML
/// body.
Err(String),
/// Serve the raw file content, using the provided content type.
Raw(String, Mime),
/// The file contents are not UTF-8; serve it from the filesystem path
/// provided.
Bin(PathBuf),
}
/// Define the data structure used to pass data between the CodeChat Editor
/// Client, the IDE, and the CodeChat Editor Server.
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct EditorMessage {
/// A value unique to this message; it's used to report results
/// (success/failure) back to the sender.
id: f64,
/// The actual message.
message: EditorMessageContents,
}
/// Define the data structure used to pass data between the CodeChat Editor
/// Client, the CodeChat Editor IDE extension, and the CodeChat Editor Server.
#[derive(Debug, Serialize, Deserialize, PartialEq)]
enum EditorMessageContents {
// #### These messages may be sent by either the IDE or the Client.
/// This sends an update; any missing fields are unchanged. Valid
/// destinations: IDE, Client.
Update(UpdateMessageContents),
/// Specify the current file to edit. Valid destinations: IDE, Client.
CurrentFile(String),
// #### These messages may only be sent by the IDE.
/// This is the first message sent when the IDE starts up. It may only be
/// sent at startup. Valid destinations: Server.
Opened(IdeType),
/// Request the Client to save any unsaved data then close. Valid
/// destinations: Client.
RequestClose,
// #### These messages may only be sent by the Server.
/// Ask the IDE if the provided file is loaded. If so, the IDE should
/// respond by sending a `LoadFile` with the requested file. If not, the
/// returned `Result` should indicate the error "not loaded". Valid
/// destinations: IDE.
LoadFile(PathBuf),
/// This may only be used to respond to an `Opened` message; it contains the
/// HTML for the CodeChat Editor Client to display in its built-in browser.
/// Valid destinations: IDE.
ClientHtml(String),
/// Sent when the IDE or Client websocket was closed, indicating that the
/// unclosed websocket should be closed as well. Therefore, this message
/// will never be received by the IDE or Client. Valid destinations: Server.
Closed,
// #### This message may be sent by anyone.
/// Sent as a response to any of the above messages, reporting
/// success/error.
Result(MessageResult),
}
/// The contents of a `Result` message.
type MessageResult = Result<
// The result of the operation, if successful.
ResultOkTypes,
// The error message.
String,
>;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
enum ResultOkTypes {
/// Most messages have no result.
Void,
/// The `LoadFile` message provides file contents, if available. This
/// message may only be sent from the IDE to the Server.
LoadFile(Option<String>),
}
/// Specify the type of IDE that this client represents.
#[derive(Debug, Serialize, Deserialize, PartialEq)]
enum IdeType {
/// True if the CodeChat Editor will be hosted inside VSCode; false means it
/// should be hosted in an external browser.
VSCode(bool),
/// Another option -- temporary -- to allow for future expansion.
DeleteMe,
}
/// Contents of the `Update` message.
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct UpdateMessageContents {
/// The filesystem path to this file. This is only used by the IDE to
/// determine which file to apply Update contents to. The Client stores then
/// then sends it back to the IDE in `Update` messages. This helps deal with
/// transition times when the IDE and Client have different files loaded,
/// guaranteeing to updates are still applied to the correct file.
file_path: String,
/// The contents of this file. TODO: this should be just a string if sent by
/// the IDE.
contents: Option<CodeChatForWeb>,
/// The current cursor position in the file, where 0 = before the first
/// character in the file and contents.length() = after the last character
/// in the file. TODO: Selections are not yet supported. TODO: how to get a
/// cursor location from within a doc block in the Client?
cursor_position: Option<u32>,
/// The normalized vertical scroll position in the file, where 0 = top and 1
/// = bottom.
scroll_position: Option<f32>,
}
/// ### Data structures used by the webserver
///
/// Define the [state](https://actix.rs/docs/application/#state) available to
/// all endpoints.
pub struct AppState {
// Provide methods to control the server.
server_handle: Mutex<Option<ServerHandle>>,
// The number of the next connection ID to assign.
connection_id: Mutex<u32>,
// The port this server listens on.
port: u16,
// For each connection ID, store a queue tx for the HTTP server to send
// requests to the processing task for that ID.
processing_task_queue_tx: Arc<Mutex<HashMap<String, Sender<ProcessingTaskHttpRequest>>>>,
// For each (connection ID, requested URL) store channel to send the
// matching response to the HTTP task.
filewatcher_client_queues: Arc<Mutex<HashMap<String, WebsocketQueues>>>,
// For each connection ID, store the queues for the VSCode IDE.
vscode_ide_queues: Arc<Mutex<HashMap<String, WebsocketQueues>>>,
vscode_client_queues: Arc<Mutex<HashMap<String, WebsocketQueues>>>,
// Connection IDs that are currently in use.
vscode_connection_id: Arc<Mutex<HashSet<String>>>,
}
// ## Macros
/// Create a macro to report an error when enqueueing an item.
#[macro_export]
macro_rules! oneshot_send {
// Provide two options: `break` or `break 'label`.
($tx: expr) => {
if let Err(err) = $tx {
error!("Unable to enqueue: {err:?}");
break;
}
};
($tx: expr, $label: tt) => {
if let Err(err) = $tx {
error!("Unable to enqueue: {err:?}");
break $label;
}
};
}
#[macro_export]
macro_rules! queue_send {
($tx: expr) => {
$crate::oneshot_send!($tx.await)
};
($tx: expr, $label: tt) => {
$crate::oneshot_send!($tx.await, $label)
};
}
/// ## Globals
///
/// The IP address on which the server listens for incoming connections.
pub const IP_ADDRESS: &str = "127.0.0.1";
// The timeout for a reply from a websocket. Use a short timeout to speed up
// unit tests.
const REPLY_TIMEOUT: Duration = if cfg!(test) {
Duration::from_millis(500)
} else {
Duration::from_millis(15000)
};
/// The time to wait for a pong from the websocket in response to a ping sent by
/// this server.
const WEBSOCKET_PING_DELAY: Duration = Duration::from_secs(2);
/// The initial value for a message ID.
const INITIAL_MESSAGE_ID: f64 = if cfg!(test) {
// A simpler value when testing.
0.0
} else {
// In production, start with the smallest whole number exactly
// representable. This is -9007199254740991.
-((1i64 << f64::MANTISSA_DIGITS) - 1) as f64
};
/// The increment for a message ID. Since the Client, IDE, and Server all
/// increment by this same amount but start at different values, this ensure
/// that message IDs will be unique. (Given a mantissa of 53 bits plus a sign
/// bit, 2^54 seconds = 574 million years before the message ID wraps around
/// assuming an average of 1 message/second.)
const MESSAGE_ID_INCREMENT: f64 = 3.0;
lazy_static! {
// Define the location of the root path, which contains `static/`,
// `log4rs.yml`, and `hashLocations.json` in a production build, or
// `client/` and `server/` in a development build.
static ref ROOT_PATH: PathBuf = {
let exe_path = env::current_exe().unwrap();
let exe_dir = exe_path.parent().unwrap();
let mut root_path = PathBuf::from(exe_dir);
// When in debug or running tests, use the layout of the Git repo to
// find client files. In release mode, we assume the static folder is a
// subdirectory of the directory containing the executable.
#[cfg(test)]
root_path.push("..");
// Note that `debug_assertions` is also enabled for testing, so this
// adds to the previous line when running tests.
#[cfg(debug_assertions)]
root_path.push("../../..");
root_path.canonicalize().unwrap()
};
// Define the location of static files.
static ref CLIENT_STATIC_PATH: PathBuf = {
let mut client_static_path = ROOT_PATH.clone();
#[cfg(debug_assertions)]
client_static_path.push("client");
client_static_path.push("static");
client_static_path
};
// Read in the hashed names of files bundled by esbuild.
static ref BUNDLED_FILES_MAP: HashMap<String, String> = {
let mut hl = ROOT_PATH.clone();
#[cfg(debug_assertions)]
hl.push("server");
hl.push("hashLocations.json");
let json = fs::read_to_string(hl).unwrap();
let hmm: HashMap<String, String> = serde_json::from_str(&json).unwrap();
hmm
};
}
// ## Webserver functionality
#[get("/ping")]
async fn ping() -> HttpResponse {
HttpResponse::Ok().body("pong")
}
#[get("/stop")]
async fn stop(app_state: web::Data<AppState>) -> HttpResponse {
let Some(ref server_handle) = *app_state.server_handle.lock().unwrap() else {
error!("Server handle not available to stop server.");
return HttpResponse::InternalServerError().finish();
};
// Don't await this, since that shuts down the server, preventing the
// following HTTP response. Assign it to a variable to suppress the warning.
drop(server_handle.stop(true));
HttpResponse::NoContent().finish()
}
/// Assign an ID to a new connection.
#[get("/id")]
async fn connection_id_endpoint(
req: HttpRequest,
body: web::Payload,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, Error> {
let (response, mut session, _msg_stream) = actix_ws::handle(&req, body)?;
actix_rt::spawn(async move {
if let Err(err) = session
.text(get_connection_id(&app_state).to_string())
.await
{
error!("Unable to send connection ID: {err}");
}
if let Err(err) = session.close(None).await {
error!("Unable to close connection: {err}");
}
});
Ok(response)
}
/// Return a unique ID for an IDE websocket connection.
fn get_connection_id(app_state: &web::Data<AppState>) -> u32 {
let mut connection_id = app_state.connection_id.lock().unwrap();
*connection_id += 1;
*connection_id
}
// Get the `mode` query parameter to determine `is_test_mode`; default to
// `false`.
pub fn get_test_mode(req: &HttpRequest) -> bool {
let query_params = web::Query::<HashMap<String, String>>::from_query(req.query_string());
if let Ok(query) = query_params {
query.get("test").is_some()
} else {
false
}
}
// Return an instance of the Client.
fn get_client_framework(
// True if the page should enable test mode for Clients it loads.
is_test_mode: bool,
// The URL prefix for a websocket connection to the Server.
ide_path: &str,
// The ID of the websocket connection.
connection_id: &str,
// This returns a response (the Client, or an error).
) -> Result<String, String> {
// Provide the pathname to the websocket connection. Quote the string using
// JSON to handle any necessary escapes.
let ws_url = match serde_json::to_string(&format!("{ide_path}/{connection_id}")) {
Ok(v) => v,
Err(err) => {
return Err(format!(
"Unable to encode websocket URL for {ide_path}, id {connection_id}: {err}"
))
}
};
let codechat_editor_framework_js = BUNDLED_FILES_MAP.get("CodeChatEditorFramework.js").unwrap();
// Build and return the webpage.
Ok(formatdoc!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The CodeChat Editor</title>
<script>
MathJax = {{
tex: {{
inlineMath: [['$', '$'], ['\\(', '\\)']]
}},
svg: {{
fontCache: 'global'
}}
}};
</script>
<script type="text/javascript" id="MathJax-script" async
src="/static/mathjax/es5/tex-chtml.js"></script>
<script type="module">
import {{ page_init }} from "/{codechat_editor_framework_js}"
page_init({ws_url}, {is_test_mode})
</script>
</head>
<body style="margin: 0px; padding: 0px; overflow: hidden">
<iframe id="CodeChat-iframe"
style="width:100%; height:100vh; border:none;"
srcdoc="<!DOCTYPE html>
<html lang='en'>
<body style='background-color:#f0f0ff'>
<div style='display:flex;justify-content:center;align-items:center;height:95vh;'>
<div style='text-align:center;font-family:Trebuchet MS;'>
<h1>The CodeChat Editor</h1>
<p>Waiting for initial render. Switch the active source code window to begin.</p>
</div>
</div>
</body>
</html>"
>
</iframe>
</body>
</html>"#
))
}
// ### Serve file
/// This could be a plain text file (for example, one not recognized as source
/// code that this program supports), a binary file (image/video/etc.), a
/// CodeChat Editor file, or a non-existent file. Determine which type this file
/// is then serve it. Serve a CodeChat Editor Client webpage using the
/// FileWatcher "IDE".
pub async fn filesystem_endpoint(
request_path: web::Path<(String, String)>,
req: &HttpRequest,
app_state: &web::Data<AppState>,
) -> HttpResponse {
let (connection_id, file_path) = request_path.into_inner();
let file_path = match PathBuf::from_str(&file_path) {
// For Windows, use `absolute` to switch to Windows path separators.
Ok(v) => match path::absolute(&v) {
Ok(v) => v,
Err(err) => {
let msg = format!("Error: unable to resolve absolute path {file_path}: {err}.");
error!("{msg}");
return html_not_found(&msg);
}
},
Err(err) => {
let msg = format!("Error: unable to convert path {file_path}: {err}.");
error!("{msg}");
return html_not_found(&msg);
}
};
// Get the `mode` query parameter to determine `is_toc`; default to `false`.
let query_params: Result<
web::Query<HashMap<String, String>>,
actix_web::error::QueryPayloadError,
> = web::Query::<HashMap<String, String>>::from_query(req.query_string());
let is_toc = query_params.map_or(false, |query| {
query.get("mode").map_or(false, |mode| mode == "toc")
});
let is_test_mode = get_test_mode(req);
// Create a one-shot channel used by the processing task to provide a
// response to this request.
let (tx, rx) = oneshot::channel();
let processing_tx = {
// Get the processing queue; only keep the lock during this block.
let processing_queue_tx = app_state.processing_task_queue_tx.lock().unwrap();
let Some(processing_tx) = processing_queue_tx.get(&connection_id) else {
let msg = format!(
"Error: no processing task queue for connection id {}.",
&connection_id
);
error!("{msg}");
return html_not_found(&msg);
};
processing_tx.clone()
};
// Send it the request.
if let Err(err) = processing_tx
.send(ProcessingTaskHttpRequest {
file_path,
is_toc,
is_test_mode,
response_queue: tx,
})
.await
{
let msg = format!("Error: unable to enqueue: {err}.");
error!("{msg}");
return html_not_found(&msg);
}
// Return the response provided by the processing task.
match rx.await {
Ok(simple_http_response) => match simple_http_response {
SimpleHttpResponse::Ok(body) => HttpResponse::Ok()
.content_type(ContentType::html())
.body(body),
SimpleHttpResponse::Err(body) => html_not_found(&body),
SimpleHttpResponse::Raw(body, content_type) => {
HttpResponse::Ok().content_type(content_type).body(body)
}
SimpleHttpResponse::Bin(path) => {
match actix_files::NamedFile::open_async(&path).await {
Ok(v) => v.into_response(req),
Err(err) => html_not_found(&format!("<p>Error opening file {path:?}: {err}.",)),
}
}
},
Err(err) => html_not_found(&format!("Error: {err}")),
}
}
// Use the provided HTTP request to look for the requested file, returning it as
// an HTTP response. This should be called from within a processing task.
async fn make_simple_http_response(
// The HTTP request presented to the processing task.
http_request: &ProcessingTaskHttpRequest,
// Path to the file currently being edited.
current_filepath: &Path,
) -> (
// The response to send back to the HTTP endpoint.
SimpleHttpResponse,
// If this file is currently being edited, this is the body of an `Update`
// message to send.
Option<EditorMessageContents>,
) {
// Convert the provided URL back into a file name.
let file_path = &http_request.file_path;
// Read the file
match File::open(file_path).await {
Err(err) => (
SimpleHttpResponse::Err(format!("<p>Error opening file {file_path:?}: {err}.")),
None,
),
Ok(mut fc) => {
let mut file_contents = String::new();
match fc.read_to_string(&mut file_contents).await {
// If this is a binary file (meaning we can't read the contents
// as UTF-8), just serve it raw; assume this is an
// image/video/etc.
Err(_) => (SimpleHttpResponse::Bin(file_path.clone()), None),
Ok(_) => {
text_file_to_response(http_request, current_filepath, file_path, &file_contents)
.await
}
}
}
}
}
async fn text_file_to_response(
// The HTTP request presented to the processing task.
http_request: &ProcessingTaskHttpRequest,
// Path to the file currently being edited.
current_filepath: &Path,
file_path: &Path,
file_contents: &str,
) -> (
// The response to send back to the HTTP endpoint.
SimpleHttpResponse,
// If this file is currently being edited, this is the body of an `Update`
// message to send.
Option<EditorMessageContents>,
) {
// Compare using the canonical path first, then the absolute path if this
// fails. This is necessary because the file may not exist on the filesystem
// (only in the IDE).
let is_current = file_path
.canonicalize()
.map_or(false, |fp| fp == current_filepath)
|| path::absolute(file_path).map_or(false, |fp| fp == current_filepath);
let (simple_http_response, option_codechat_for_web) = serve_file(
file_path,
file_contents,
http_request.is_toc,
is_current,
http_request.is_test_mode,
)
.await;
let Some(file_path) = file_path.to_str() else {
let msg = format!("Error: unable to convert path {file_path:?} to a string.");
error!("{msg}");
return (SimpleHttpResponse::Err(msg), None);
};
// If this file is editable and is the main file, send an `Update`. The
// `simple_http_response` contains the Client.
(
simple_http_response,
option_codechat_for_web.map(|codechat_for_web| {
EditorMessageContents::Update(UpdateMessageContents {
file_path: file_path.to_string(),
contents: Some(codechat_for_web),
cursor_position: None,
scroll_position: None,
})
}),
)
}
async fn serve_file(
file_path: &Path,
file_contents: &str,
is_toc: bool,
is_current_file: bool,
is_test_mode: bool,
) -> (SimpleHttpResponse, Option<CodeChatForWeb>) {
// Provided info from the HTTP request, determine the following parameters.
let raw_dir = file_path.parent().unwrap();
// Use a lossy conversion, since this is UI display, not filesystem access.
let dir = path_display(raw_dir);
let name = escape_html(&file_path.file_name().unwrap().to_string_lossy());
// Get the locations for bundled files.
let js_test_suffix = if is_test_mode { "-test" } else { "" };
let codechat_editor_js = BUNDLED_FILES_MAP
.get(&format!("CodeChatEditor{js_test_suffix}.js"))
.unwrap();
let codehat_editor_css = BUNDLED_FILES_MAP
.get(&format!("CodeChatEditor{js_test_suffix}.css"))
.unwrap();
// See if this is a CodeChat Editor file.
let (translation_results_string, path_to_toc) = if is_current_file || is_toc {
source_to_codechat_for_web_string(file_contents, file_path, is_toc)
} else {
// If this isn't the current file, then don't parse it.
(TranslationResultsString::Unknown, None)
};
let is_project = path_to_toc.is_some();
let codechat_for_web = match translation_results_string {
// The file type is unknown. Serve it raw.
TranslationResultsString::Unknown => {
return (
SimpleHttpResponse::Raw(
file_contents.to_string(),
mime_guess::from_path(file_path).first_or_text_plain(),
),
None,
);
}
// Report a lexer error.
TranslationResultsString::Err(err_string) => {
return (SimpleHttpResponse::Err(err_string), None)
}
// This is a CodeChat file. The following code wraps the CodeChat for
// web results in a CodeChat Editor Client webpage.
TranslationResultsString::CodeChat(codechat_for_web) => codechat_for_web,
TranslationResultsString::Toc(html) => {
// The TOC is a simplified web page which requires no additional
// processing.
return (
SimpleHttpResponse::Ok(formatdoc!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{name} - The CodeChat Editor</title>
<link rel="stylesheet" href="/{codehat_editor_css}">
</head>
<body class="CodeChat-theme-light" onload="parent.window.parent.window.MathJax.typesetPromise([document.body])">
<div class="CodeChat-TOC">
{html}
</div>
</body>
</html>"#,
)),
None,
);
}
};
// For project files, add in the sidebar. Convert this from a Windows path
// to a Posix path if necessary.
let (sidebar_iframe, sidebar_css) = if is_project {
(
format!(
r#"<iframe src="{}?mode=toc" id="CodeChat-sidebar"></iframe>"#,
path_to_toc.unwrap().to_slash_lossy()
),
format!(
r#"<link rel="stylesheet" href="/{}">"#,
BUNDLED_FILES_MAP.get("CodeChatEditorProject.css").unwrap()
),
)
} else {
("".to_string(), "".to_string())
};
// Add testing mode scripts if requested.
let testing_src = if is_test_mode {
r#"
<link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
<script src="https://unpkg.com/mocha/mocha.js"></script>
"#
} else {
""
};
// Build and return the webpage.
(
SimpleHttpResponse::Ok(formatdoc!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{name} - The CodeChat Editor</title>
<script type="module">
import {{ page_init }} from "/{codechat_editor_js}"
page_init()
</script>
<link rel="stylesheet" href="/{codehat_editor_css}">
{testing_src}
{sidebar_css}
</head>
<body class="CodeChat-theme-light">
{sidebar_iframe}
<div id="CodeChat-contents">
<div id="CodeChat-top">
<div id="CodeChat-filename">
<p>
{name} - {dir}
</p>
</div>
<div id="CodeChat-menu"></div>
</div>
<div id="CodeChat-body"></div>
<div id="CodeChat-bottom"></div>
<div id="mocha"></div>
</div>
</body>
</html>"#
)),
Some(codechat_for_web),
)
}
/// ## Websockets
///
/// Each CodeChat Editor IDE instance pairs with a CodeChat Editor Client
/// through the CodeChat Editor Server. Together, these form a joint editor,
/// allowing the user to edit the plain text of the source code in the IDE, or
/// make GUI-enhanced edits of the source code rendered by the CodeChat Editor
/// Client.
async fn client_websocket(
connection_id: web::Path<String>,
req: HttpRequest,
body: web::Payload,
websocket_queues: Arc<Mutex<HashMap<String, WebsocketQueues>>>,
) -> Result<HttpResponse, Error> {
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
// Websocket task: start a task to handle receiving `JointMessage` websocket
// data from the CodeChat Editor Client and forwarding it to the IDE and
// vice versa. It also handles low-level details (ping/pong, websocket
// errors/closing).
actix_rt::spawn(async move {
msg_stream = msg_stream.max_frame_size(1_000_000);
let mut aggregated_msg_stream = msg_stream.aggregate_continuations();
aggregated_msg_stream = aggregated_msg_stream.max_continuation_size(10_000_000);
// Transfer the queues from the global state to this task.
let (from_websocket_tx, mut to_websocket_rx) = match websocket_queues
.lock()
.unwrap()
.remove(&connection_id.to_string())
{
Some(queues) => (queues.from_websocket_tx.clone(), queues.to_websocket_rx),
None => {
error!("No websocket queues for connection id {connection_id}.");
return;
}
};
// Keep track of pending messages.
let mut pending_messages: HashMap<u64, JoinHandle<()>> = HashMap::new();
// Shutdown may occur in a controlled process or an immediate websocket
// close. If the Client needs to close, it can simply close its
// websocket, since the IDE maintains all state (case 2). However, if
// the IDE plugin needs to close, it should inform the Client first, so
// the Client can send the IDE any unsaved data (case 1). However, bad
// things can also happen; if either websocket connection is closed,
// then the other websocket should also be immediately closed (also case
// 2).
//
// 1. The IDE plugin needs to close.
// 1. The IDE plugin sends a `Closed` message.
// 2. The Client replies with a `Result` message, acknowledging the
// close. It sends an `Update` message if necessary to save the
// current file.
// 3. After receiving the acknowledge from the Update message (if
// sent), the Client closes the websocket. The rest of this
// sequence is covered in the next case.
// 2. Either websocket is closed. In this case, the other websocket
// should be immediately closed; there's no longer the opportunity
// to perform a more controlled shutdown (see the first case).
// 1. The websocket which closed enqueues a `Closed` message for
// the other websocket.
// 2. When the other websocket receives this message, it closes.
//
// True when the websocket's client deliberately closes the websocket;
// otherwise, closing represents a network interruption (such as the
// computer going to sleep).
let mut is_closing = false;
// True if a ping was sent, but a matching pong wasn't yet received.
let mut sent_ping = false;
loop {
select! {
// Send pings on a regular basis.
_ = sleep(WEBSOCKET_PING_DELAY) => {
if sent_ping {
// If we haven't received the answering pong, the
// websocket must be broken.
break;
}
// Send a ping to check that the websocket is still open.
// For example, putting a PC to sleep then waking it breaks
// the websocket, but the server doesn't detect this without
// sending a ping (which then fails).
if let Err(err) = session.ping(&Bytes::new()).await {
error!("Unable to send ping: {err}");
break;
}
sent_ping = true;
}
// Process a message received from the websocket.
Some(msg_wrapped) = aggregated_msg_stream.next() => {
match msg_wrapped {
Ok(msg) => {
match msg {
// Send a pong in response to a ping.
AggregatedMessage::Ping(bytes) => {
if let Err(err) = session.pong(&bytes).await {
error!("Unable to send pong: {err}");
break;
}
}
AggregatedMessage::Pong(_bytes) => {
// Acknowledge the matching pong to the ping
// that was most recently sent.
sent_ping = false;
}
// Decode text messages as JSON then dispatch
// then to the IDE.
AggregatedMessage::Text(b) => {
// The CodeChat Editor Client should always
// send valid JSON.
match serde_json::from_str::<EditorMessage>(&b) {
Err(err) => {
error!(
"Unable to decode JSON message from the CodeChat Editor IDE or client: {err}.\nText was: '{b}'."
);
break;
}
Ok(joint_message) => {
// If this was a `Result`, remove it from
// the pending queue.
if let EditorMessageContents::Result(_) = joint_message.message {
// Cancel the timeout for this result.
if let Some(task) = pending_messages.remove(&joint_message.id.to_bits()) {
task.abort();
}
}
// Check for messages that only the server
// can send.
match &joint_message.message {
// Check for an invalid message.
EditorMessageContents::LoadFile(_) |
EditorMessageContents::ClientHtml(_) |
EditorMessageContents::Closed => {
let msg = format!("Invalid message {joint_message:?}");
error!("{msg}");
queue_send!(from_websocket_tx.send(EditorMessage {
id: joint_message.id,
message: EditorMessageContents::Result(Err(msg))
}));
},
// Send everything else.
_ => {
// Send the `JointMessage` to the
// processing task.
queue_send!(from_websocket_tx.send(joint_message));
}
}
}
}
}
// Forward a close message from the client to
// the IDE, so that both this websocket
// connection and the other connection will both
// be closed.
AggregatedMessage::Close(reason) => {
info!("Closing per client request: {reason:?}");
is_closing = true;
queue_send!(from_websocket_tx.send(EditorMessage { id: 0.0, message: EditorMessageContents::Closed }));
break;
}
other => {
warn!("Unexpected message {other:?}");
break;
}
}
}
Err(err) => {
error!("websocket receive error {err:?}");
}
}
}
// Forward a message from the processing task to the websocket.
Some(m) = to_websocket_rx.recv() => {
// Pre-process this message.
match m.message {
// If it's a `Result`, no additional processing is
// needed.
EditorMessageContents::Result(_) => {},
// A `Closed` message causes the websocket to close.
EditorMessageContents::Closed => {
info!("Closing per request.");
is_closing = true;
break;
},
// All other messages are added to the pending queue and
// assigned a unique id.
_ => {
let timeout_tx = from_websocket_tx.clone();