1
1
<template >
2
+ <!-- Container that fills the available height and hosts the terminal -->
2
3
<div class =" ma-0 pa-0 w-100 fill-height position-relative" >
4
+ <!-- The xterm.js terminal will be mounted here -->
3
5
<div ref =" terminal" class =" terminal" data-test =" terminal-container" />
4
6
</div >
5
7
</template >
6
8
7
9
<script setup lang="ts">
8
10
import { onMounted , onUnmounted , ref } from " vue" ;
9
11
import { useEventListener } from " @vueuse/core" ;
12
+
13
+ // Terminal styles and required classes
10
14
import " xterm/css/xterm.css" ;
11
15
import { Terminal } from " xterm" ;
12
16
import { FitAddon } from " xterm-addon-fit" ;
13
- import { IParams } from " @/interfaces/IParams" ;
14
- import { InputMessage , MessageKind , ResizeMessage , WebTermDimensions } from " @/interfaces/ITerminal" ;
15
17
16
- const { token } = defineProps <{
17
- token: string ;
18
+ // Type definitions
19
+ import { IParams } from " @/interfaces/IParams" ;
20
+ import {
21
+ InputMessage ,
22
+ MessageKind ,
23
+ ResizeMessage ,
24
+ SignatureMessage ,
25
+ WebTermDimensions ,
26
+ } from " @/interfaces/ITerminal" ;
27
+
28
+ // SSH signing utilities
29
+ import {
30
+ parsePrivateKeySsh ,
31
+ createSignatureOfPrivateKey ,
32
+ createSignerPrivateKey ,
33
+ } from " @/utils/validate" ;
34
+ import handleError from " @/utils/handleError" ;
35
+
36
+ // Props passed to the component
37
+ const { token, privateKey } = defineProps <{
38
+ token: string ; // JWT token for WebSocket authentication
39
+ privateKey? : string | null ; // Optional SSH private key for challenge-response auth
18
40
}>();
19
41
20
- const terminal = ref <HTMLElement >({} as HTMLElement );
21
- const xterm = ref <Terminal >({} as Terminal );
22
- const fitAddon = ref <FitAddon >(new FitAddon ());
23
- const ws = ref <WebSocket >({} as WebSocket );
24
- const textEncoder = new TextEncoder ();
42
+ // Refs and runtime state
43
+ const terminal = ref <HTMLElement >({} as HTMLElement ); // Terminal DOM container
44
+ const xterm = ref <Terminal >({} as Terminal ); // xterm.js terminal instance
45
+ const fitAddon = ref <FitAddon >(new FitAddon ()); // Auto-fit terminal to container
46
+ const ws = ref <WebSocket >({} as WebSocket ); // Active WebSocket connection
47
+ const textEncoder = new TextEncoder (); // Converts strings to Uint8Array
48
+ const isReady = ref (false ); // Tracks if WS is open and ready
25
49
50
+ // Initialize terminal instance and attach the fit addon
26
51
const initializeTerminal = () => {
27
52
xterm .value = new Terminal ({
28
53
cursorBlink: true ,
@@ -35,65 +60,122 @@ const initializeTerminal = () => {
35
60
xterm .value .loadAddon (fitAddon .value );
36
61
};
37
62
63
+ // Get terminal dimensions for WebSocket URL
38
64
const getWebTermDimensions = (): WebTermDimensions => ({
39
65
cols: xterm .value .cols ,
40
66
rows: xterm .value .rows ,
41
67
});
42
68
69
+ // Convert token and dimensions into query params for WS URL
43
70
const encodeURLParams = (params : IParams ): string => Object .entries (params ).map (([key , value ]) => ` ${key }=${value } ` ).join (" &" );
44
71
45
- const isWebSocketOpen = () => ws .value .readyState === WebSocket .OPEN ;
72
+ // Check if WebSocket is open and usable
73
+ const isWebSocketOpen = () => isReady .value && ws .value .readyState === WebSocket .OPEN ;
46
74
75
+ // Construct the WebSocket URL with protocol, host, and query
47
76
const getWebSocketUrl = (dimensions : WebTermDimensions ): string => {
48
77
const protocol = window .location .protocol === " http:" ? " ws" : " wss" ;
49
78
const wsInfo = { token , ... dimensions };
50
79
51
80
return ` ${protocol }://${window .location .host }/ws/ssh?${encodeURLParams (wsInfo )} ` ;
52
81
};
53
82
83
+ // Set up terminal events for user input and resize events
54
84
const setupTerminalEvents = () => {
85
+ // Send user input over WebSocket
55
86
xterm .value .onData ((data ) => {
56
87
if (! isWebSocketOpen ()) return ;
57
88
58
89
const message: InputMessage = {
59
90
kind: MessageKind .Input ,
60
91
data: [... textEncoder .encode (data )],
61
92
};
93
+
62
94
ws .value .send (JSON .stringify (message ));
63
95
});
64
96
65
- xterm .value .onResize ((data ) => {
97
+ // Send terminal resize info over WebSocket
98
+ xterm .value .onResize (({ cols , rows }) => {
66
99
if (! isWebSocketOpen ()) return ;
67
100
68
101
const message: ResizeMessage = {
69
102
kind: MessageKind .Resize ,
70
- data: { cols: data . cols , rows: data . rows },
103
+ data: { cols , rows },
71
104
};
105
+
72
106
ws .value .send (JSON .stringify (message ));
73
107
});
74
108
};
75
109
110
+ // Write text output to the terminal UI
111
+ const writeToTerminal = (data : string ) => {
112
+ xterm .value .write (data );
113
+ };
114
+
115
+ // Handles signing of SSH challenge using the user's private key
116
+ const signWebSocketChallenge = async (
117
+ key : string ,
118
+ base64Challenge : string ,
119
+ ): Promise <string > => {
120
+ const challenge = atob (base64Challenge );
121
+ const parsedKey = parsePrivateKeySsh (key );
122
+
123
+ if (parsedKey .type === " ed25519" ) {
124
+ return createSignerPrivateKey (parsedKey , challenge );
125
+ }
126
+
127
+ return decodeURIComponent (await createSignatureOfPrivateKey (parsedKey , challenge ));
128
+ };
129
+
130
+ // Initialize WebSocket and its message handling
76
131
const setupWebSocketEvents = () => {
77
132
ws .value .onopen = () => {
78
- fitAddon .value .fit ();
79
- };
80
-
81
- ws .value .onmessage = (event ) => {
82
- xterm .value .write (event .data );
133
+ fitAddon .value .fit (); // Adjust terminal to container
134
+ isReady .value = true ;
135
+
136
+ // If using public key auth, expect challenge message first
137
+ if (privateKey ) {
138
+ ws .value .onmessage = async (event ) => {
139
+ try {
140
+ const parsed = JSON .parse (event .data ) as SignatureMessage ;
141
+ if (parsed .kind === MessageKind .Signature ) {
142
+ const signature = await signWebSocketChallenge (privateKey , parsed .data );
143
+ ws .value .send (JSON .stringify ({ kind: 3 , data: signature }));
144
+
145
+ // After challenge is signed, switch to raw message handling
146
+ ws .value .onmessage = (e ) => writeToTerminal (e .data );
147
+ }
148
+ } catch (err ) {
149
+ writeToTerminal (" \r\n Failed to sign challenge.\r\n " );
150
+ handleError (err );
151
+ ws .value .close ();
152
+ }
153
+ };
154
+
155
+ return ; // Skip password-mode handler
156
+ }
157
+
158
+ // For password-based logins, simply write messages to the terminal
159
+ ws .value .onmessage = (event ) => {
160
+ writeToTerminal (event .data );
161
+ };
83
162
};
84
163
85
164
ws .value .onclose = () => {
86
- xterm .value .write (" \r\n Connection ended" );
165
+ writeToTerminal (" \r\n Connection ended\r\n " );
166
+ isReady .value = false ;
87
167
};
88
168
};
89
169
170
+ // Connect and initialize WebSocket session
90
171
const initializeWebSocket = () => {
91
172
const dimensions = getWebTermDimensions ();
92
173
const wsUrl = getWebSocketUrl (dimensions );
93
174
ws .value = new WebSocket (wsUrl );
94
175
setupWebSocketEvents ();
95
176
};
96
177
178
+ // Mount lifecycle: Initialize terminal and WebSocket
97
179
onMounted (() => {
98
180
initializeTerminal ();
99
181
xterm .value .open (terminal .value );
@@ -103,21 +185,23 @@ onMounted(() => {
103
185
initializeWebSocket ();
104
186
});
105
187
188
+ // Resize the terminal when window is resized
106
189
useEventListener (window , " resize" , () => {
107
190
fitAddon .value .fit ();
108
191
});
109
192
193
+ // Cleanup lifecycle: close WebSocket if active
110
194
onUnmounted (() => {
111
195
if (isWebSocketOpen ()) ws .value .close ();
112
196
});
113
197
198
+ // Optional expose for testing or parent communication
114
199
defineExpose ({ xterm , ws });
115
200
</script >
116
201
117
202
<style scoped lang="scss">
118
203
.terminal {
119
204
position : absolute ;
120
- inset : 0 ;
121
- margin-right : 0px ;
205
+ inset : 0 ; // Fills the container completely
122
206
}
123
207
</style >
0 commit comments