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 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
+ }