slidev-addon-web-terminal 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/components/WebTerminal.vue +238 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Slidev Addon Web Terminal
|
|
2
|
+
|
|
3
|
+
This addon provides a `WebTerminal` component for [Slidev](https://sli.dev/) presentations, allowing you to embed a fully functional terminal connected to a backend process.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Xterm.js Integration**: Uses a full-featured terminal emulator.
|
|
8
|
+
- **Backend Connection**: Connects to a backend WebSocket/API service.
|
|
9
|
+
- See: [berttejeda/bert.webterminal](https://github.com/berttejeda/bert.webterminal)
|
|
10
|
+
- **Zero-Config Dynamic Proxy**: Specify any `backendUrl` (including different domains and ports) in your markdown, and the addon handles CORS and proxying automatically.
|
|
11
|
+
- **Click to Execute**: Commands are automatically sent to the terminal when clicking an element with the `.clickable-code` class (e.g. a wrapper around a code block).
|
|
12
|
+
- **Auto-fit**: Automatically resizes the terminal to fit the container.
|
|
13
|
+
- **Theme Support**: Styled for dark mode by default.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install slidev-addon-web-terminal
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Backend Setup
|
|
22
|
+
|
|
23
|
+
This addon requires a backend service to handle the terminal sessions.
|
|
24
|
+
|
|
25
|
+
To get started quickly, run the [Webterminal Agent](https://github.com/berttejeda/bert.webterminal) using Docker:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
docker run -d --name webterminal --rm -p {{ HOSTPORT }}:10001 berttejeda/bill-webterminal
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Example (port 10001):
|
|
32
|
+
```bash
|
|
33
|
+
docker run -d --name webterminal --rm -p 10001:10001 berttejeda/bill-webterminal
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
To enable the **Dynamic Port Proxy** (which solves CORS issues when using different hosts or ports), you must add the proxy plugin to your `vite.config.ts`.
|
|
39
|
+
|
|
40
|
+
### 1. Install `http-proxy`
|
|
41
|
+
```bash
|
|
42
|
+
npm install -s http-proxy
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Update `vite.config.ts`
|
|
46
|
+
Add the following plugin to your Vite configuration:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { defineConfig } from 'vite'
|
|
50
|
+
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
plugins: [
|
|
53
|
+
{
|
|
54
|
+
name: 'dynamic-terminal-proxy',
|
|
55
|
+
configureServer(server) {
|
|
56
|
+
const httpProxy = require('http-proxy')
|
|
57
|
+
const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true })
|
|
58
|
+
|
|
59
|
+
const proxyPattern = /^\/proxy\/([^\/]+)\/([^\/]+)\/([^\/]+)(.*)/
|
|
60
|
+
|
|
61
|
+
server.middlewares.use((req, res, next) => {
|
|
62
|
+
const match = req.url?.match(proxyPattern)
|
|
63
|
+
if (match) {
|
|
64
|
+
const [_, protocol, host, port, rest] = match
|
|
65
|
+
const target = `${protocol}://${host}:${port}`
|
|
66
|
+
req.url = rest || '/'
|
|
67
|
+
proxy.web(req, res, { target, secure: protocol === 'https' }, (e) => {
|
|
68
|
+
res.statusCode = 502
|
|
69
|
+
res.end(`Proxy error: ${e.message}`)
|
|
70
|
+
})
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
next()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
server.httpServer?.on('upgrade', (req, socket, head) => {
|
|
77
|
+
const match = req.url?.match(proxyPattern)
|
|
78
|
+
if (match) {
|
|
79
|
+
const [_, protocol, host, port, rest] = match
|
|
80
|
+
req.url = rest || '/'
|
|
81
|
+
proxy.ws(req, socket, head, { target: `${protocol}://${host}:${port}`, secure: protocol === 'https' })
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
In your slides configuration (e.g., `slides.md`):
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
---
|
|
96
|
+
addons:
|
|
97
|
+
- web-terminal
|
|
98
|
+
---
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then use the component in your slides. You can point to any backend URL directly:
|
|
102
|
+
|
|
103
|
+
```markdown
|
|
104
|
+
<!-- Localhost with default port -->
|
|
105
|
+
<WebTerminal backendUrl="http://localhost:10001" />
|
|
106
|
+
|
|
107
|
+
<!-- Arbitrary remote host (handles CORS automatically) -->
|
|
108
|
+
<WebTerminal backendUrl="https://my-websocket-host.example.com:4443" />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Props
|
|
112
|
+
|
|
113
|
+
| Prop | Type | Default | Description |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `backendUrl` | `string` | - | The base URL of the terminal backend. If pointing to `localhost`, it automatically uses the dynamic proxy. |
|
|
116
|
+
| `wsUrl` | `string` | - | (Optional) Direct WebSocket URL to bypass session creation API. |
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# Install dependencies
|
|
122
|
+
npm install
|
|
123
|
+
|
|
124
|
+
# Run linter
|
|
125
|
+
npm run lint
|
|
126
|
+
```
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|
3
|
+
import { Terminal } from 'xterm'
|
|
4
|
+
import { FitAddon } from 'xterm-addon-fit'
|
|
5
|
+
import { WebLinksAddon } from 'xterm-addon-web-links'
|
|
6
|
+
import { AttachAddon } from 'xterm-addon-attach'
|
|
7
|
+
import 'xterm/css/xterm.css'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
wsUrl?: string
|
|
11
|
+
backendUrl?: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const terminalContainer = ref<HTMLElement | null>(null)
|
|
15
|
+
let terminal: Terminal | null = null
|
|
16
|
+
let socket: WebSocket | null = null
|
|
17
|
+
let fitAddon: FitAddon | null = null
|
|
18
|
+
let attachAddon: AttachAddon | null = null
|
|
19
|
+
let pid: string | null = null
|
|
20
|
+
|
|
21
|
+
const handleCodeClick = (e: MouseEvent) => {
|
|
22
|
+
const target = e.target as HTMLElement
|
|
23
|
+
// Only trigger if nested inside or is an element with class 'clickable-code'
|
|
24
|
+
const clickableElement = target.closest('.clickable-code') as HTMLElement | null
|
|
25
|
+
|
|
26
|
+
if (clickableElement) {
|
|
27
|
+
// Don't execute if it's inside the terminal itself
|
|
28
|
+
if (terminalContainer.value?.contains(clickableElement)) return
|
|
29
|
+
|
|
30
|
+
const code = clickableElement.innerText.trim()
|
|
31
|
+
if (code && socket && socket.readyState === WebSocket.OPEN) {
|
|
32
|
+
// Send the code to the terminal
|
|
33
|
+
socket.send(code + '\n')
|
|
34
|
+
// Visual feedback: focus the terminal
|
|
35
|
+
terminal?.focus()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const initTerminal = async () => {
|
|
41
|
+
if (!terminalContainer.value) return
|
|
42
|
+
|
|
43
|
+
// Initialize Terminal
|
|
44
|
+
terminal = new Terminal({
|
|
45
|
+
cursorBlink: true,
|
|
46
|
+
cursorStyle: 'block',
|
|
47
|
+
macOptionIsMeta: true,
|
|
48
|
+
scrollback: 10000,
|
|
49
|
+
tabStopWidth: 10,
|
|
50
|
+
allowProposedApi: true
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Initialize Addons
|
|
54
|
+
fitAddon = new FitAddon()
|
|
55
|
+
const webLinksAddon = new WebLinksAddon()
|
|
56
|
+
|
|
57
|
+
terminal.loadAddon(fitAddon)
|
|
58
|
+
terminal.loadAddon(webLinksAddon)
|
|
59
|
+
|
|
60
|
+
terminal.open(terminalContainer.value)
|
|
61
|
+
fitAddon.fit()
|
|
62
|
+
terminal.focus()
|
|
63
|
+
|
|
64
|
+
// Handle resizing
|
|
65
|
+
window.addEventListener('resize', handleResize)
|
|
66
|
+
|
|
67
|
+
// Add global click listener for 'click to execute' feature
|
|
68
|
+
document.addEventListener('click', handleCodeClick)
|
|
69
|
+
|
|
70
|
+
// Determine connection URL
|
|
71
|
+
// ... (lines 49-142 remain unchanged but I'll include them in the snippet if needed)
|
|
72
|
+
let connectionUrl = props.wsUrl
|
|
73
|
+
|
|
74
|
+
if (!connectionUrl && props.backendUrl) {
|
|
75
|
+
try {
|
|
76
|
+
let cleanBackendUrl = props.backendUrl.replace(/\/$/, '')
|
|
77
|
+
|
|
78
|
+
// Check if we are pointing to a cross-origin backend
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(cleanBackendUrl, window.location.origin)
|
|
81
|
+
if (url.origin !== window.location.origin) {
|
|
82
|
+
const protocol = url.protocol.replace(':', '')
|
|
83
|
+
const host = url.hostname
|
|
84
|
+
// url.port is empty if it's the default (80/443) or omitted
|
|
85
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
|
|
86
|
+
|
|
87
|
+
// Rewrite to use our dynamic proxy in vite.config.ts
|
|
88
|
+
// Pattern: /proxy/protocol/host/port
|
|
89
|
+
cleanBackendUrl = `/proxy/${protocol}/${host}/${port}`
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn(`URL parsing failed for backendUrl with error ${e}, falling back to original: ${cleanBackendUrl}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const response = await fetch(`${cleanBackendUrl}/api/terminals`, { method: 'POST' })
|
|
96
|
+
if (response.ok) {
|
|
97
|
+
pid = await response.text()
|
|
98
|
+
|
|
99
|
+
// Construct WS URL
|
|
100
|
+
if (cleanBackendUrl.startsWith('/proxy/')) {
|
|
101
|
+
// Use relative WS URL for the proxy
|
|
102
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
103
|
+
connectionUrl = `${protocol}//${window.location.host}${cleanBackendUrl}/terminals/${pid}`
|
|
104
|
+
} else {
|
|
105
|
+
const url = new URL(cleanBackendUrl, window.location.href)
|
|
106
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
107
|
+
connectionUrl = `${protocol}//${url.host}/terminals/${pid}`
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
console.error('Failed to create terminal:', response.statusText)
|
|
111
|
+
terminal.write(`\r\nFailed to create terminal: ${response.statusText}\r\n`)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error('Error creating terminal:', e)
|
|
116
|
+
terminal.write(`\r\nError creating terminal: ${e}\r\n`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Connect to WebSocket
|
|
122
|
+
if (connectionUrl) {
|
|
123
|
+
// If using proxy (relative backendUrl), connectionUrl might be ws://localhost:3030/terminals/pid
|
|
124
|
+
// which is correct for Vite proxy.
|
|
125
|
+
connectWebSocket(connectionUrl)
|
|
126
|
+
} else {
|
|
127
|
+
terminal.write('\r\nNo WebSocket URL or Backend URL provided.\r\n')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const connectWebSocket = (url: string) => {
|
|
132
|
+
socket = new WebSocket(url)
|
|
133
|
+
|
|
134
|
+
socket.onopen = () => {
|
|
135
|
+
if (terminal && socket) {
|
|
136
|
+
attachAddon = new AttachAddon(socket)
|
|
137
|
+
terminal.loadAddon(attachAddon)
|
|
138
|
+
|
|
139
|
+
// Resize adjustment
|
|
140
|
+
if(fitAddon) fitAddon.fit()
|
|
141
|
+
|
|
142
|
+
terminal.focus()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
socket.onclose = () => {
|
|
147
|
+
if (terminal) {
|
|
148
|
+
terminal.write('\r\nConnection closed.\r\n')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
socket.onerror = (err) => {
|
|
153
|
+
console.error("WebSocket error:", err)
|
|
154
|
+
if (terminal) {
|
|
155
|
+
terminal.write('\r\nWebSocket error.\r\n')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const handleResize = () => {
|
|
161
|
+
if (fitAddon) {
|
|
162
|
+
fitAddon.fit()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const dispose = () => {
|
|
167
|
+
window.removeEventListener('resize', handleResize)
|
|
168
|
+
document.removeEventListener('click', handleCodeClick)
|
|
169
|
+
if (socket) {
|
|
170
|
+
socket.close()
|
|
171
|
+
socket = null
|
|
172
|
+
}
|
|
173
|
+
if (terminal) {
|
|
174
|
+
terminal.dispose()
|
|
175
|
+
terminal = null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onMounted(() => {
|
|
180
|
+
initTerminal()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
onUnmounted(() => {
|
|
184
|
+
dispose()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
watch(() => [props.wsUrl, props.backendUrl], () => {
|
|
188
|
+
dispose()
|
|
189
|
+
initTerminal()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
</script>
|
|
193
|
+
<style>
|
|
194
|
+
/* Global styles for clickable code blocks */
|
|
195
|
+
.clickable-code, .clickable-code * {
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
}
|
|
198
|
+
.clickable-code {
|
|
199
|
+
transition: opacity 0.2s;
|
|
200
|
+
}
|
|
201
|
+
.clickable-code:hover {
|
|
202
|
+
opacity: 0.8;
|
|
203
|
+
}
|
|
204
|
+
.clickable-code:hover code {
|
|
205
|
+
outline: 1px dashed #555;
|
|
206
|
+
outline-offset: 2px;
|
|
207
|
+
}
|
|
208
|
+
</style>
|
|
209
|
+
|
|
210
|
+
<template>
|
|
211
|
+
<div
|
|
212
|
+
class="web-terminal-wrapper"
|
|
213
|
+
>
|
|
214
|
+
<div
|
|
215
|
+
ref="terminalContainer"
|
|
216
|
+
class="web-terminal-container"
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
</template>
|
|
220
|
+
|
|
221
|
+
<style scoped>
|
|
222
|
+
.web-terminal-wrapper {
|
|
223
|
+
width: 100%;
|
|
224
|
+
height: 100%;
|
|
225
|
+
padding: 1rem;
|
|
226
|
+
background-color: #000;
|
|
227
|
+
border-radius: 4px;
|
|
228
|
+
overflow: hidden;
|
|
229
|
+
display: flex;
|
|
230
|
+
flex-direction: column;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.web-terminal-container {
|
|
234
|
+
width: 100%;
|
|
235
|
+
height: 100%;
|
|
236
|
+
flex: 1; /* Grow to fill available space */
|
|
237
|
+
}
|
|
238
|
+
</style>
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slidev-addon-web-terminal",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A Slidev addon that provides a web terminal using xterm.js",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"slidev-addon",
|
|
7
|
+
"slidev",
|
|
8
|
+
"terminal",
|
|
9
|
+
"xterm"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "eslint ."
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"slidev": {
|
|
16
|
+
"components": [
|
|
17
|
+
"components/WebTerminal.vue"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"components",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"xterm": "^5.3.0",
|
|
26
|
+
"xterm-addon-fit": "^0.8.0",
|
|
27
|
+
"xterm-addon-web-links": "^0.9.0",
|
|
28
|
+
"xterm-addon-attach": "^0.9.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@slidev/cli": ">=0.40.0",
|
|
32
|
+
"vue": "^3.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@eslint/js": "^9.0.0",
|
|
36
|
+
"@slidev/cli": "^0.49.0",
|
|
37
|
+
"@slidev/theme-default": "latest",
|
|
38
|
+
"eslint": "^9.0.0",
|
|
39
|
+
"eslint-plugin-vue": "^9.0.0",
|
|
40
|
+
"typescript-eslint": "^8.0.0",
|
|
41
|
+
"vue-eslint-parser": "^9.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|