@lvce-editor/server 0.102.3 → 0.103.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.
@@ -2,7 +2,7 @@ This project incorporates components from the projects listed below, that may ha
2
2
  differing from this project:
3
3
 
4
4
 
5
- 1) License Notice for static/6580ba2/icons (from https://github.com/microsoft/vscode-codicons)
5
+ 1) License Notice for static/8afc276/icons (from https://github.com/microsoft/vscode-codicons)
6
6
  ---------------------------------------
7
7
 
8
8
  Attribution 4.0 International
@@ -402,7 +402,7 @@ public licenses.
402
402
  Creative Commons may be contacted at creativecommons.org.
403
403
 
404
404
 
405
- 2) License Notice for static/6580ba2/fonts/FiraCode-VariableFont.ttf (from https://github.com/tonsky/FiraCode)
405
+ 2) License Notice for static/8afc276/fonts/FiraCode-VariableFont.ttf (from https://github.com/tonsky/FiraCode)
406
406
  ---------------------------------------
407
407
 
408
408
  Copyright (c) 2014, The Fira Code Project Authors (https://github.com/tonsky/FiraCode)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/server",
3
- "version": "0.102.3",
3
+ "version": "0.103.0",
4
4
  "description": "Run LVCE Editor as a server.",
5
5
  "main": "index.js",
6
6
  "bin": "bin/server.js",
@@ -20,7 +20,7 @@
20
20
  "node": ">=24"
21
21
  },
22
22
  "dependencies": {
23
- "@lvce-editor/shared-process": "0.102.3",
24
- "@lvce-editor/static-server": "0.102.3"
23
+ "@lvce-editor/shared-process": "0.103.0",
24
+ "@lvce-editor/static-server": "0.103.0"
25
25
  }
26
26
  }
@@ -0,0 +1,62 @@
1
+ const defaultIdleTimeout = 3 * 60 * 60 * 1000
2
+
3
+ const getArgument = (argv, name) => {
4
+ const prefix = `${name}=`
5
+ const argument = argv.find((value) => value.startsWith(prefix))
6
+ return argument ? argument.slice(prefix.length) : undefined
7
+ }
8
+
9
+ const parseInteger = (value, fallback, name) => {
10
+ if (value === undefined) {
11
+ return fallback
12
+ }
13
+ const parsed = Number.parseInt(value, 10)
14
+ if (!Number.isSafeInteger(parsed)) {
15
+ throw new TypeError(`${name} must be an integer`)
16
+ }
17
+ return parsed
18
+ }
19
+
20
+ export const getRemoteSshOptions = (argv, env = process.env) => {
21
+ const enabled = argv.includes('--as-remote-ssh-server')
22
+ if (!enabled) {
23
+ return {
24
+ enabled: false,
25
+ host: env.HOST || 'localhost',
26
+ idleTimeout: defaultIdleTimeout,
27
+ port: parseInteger(env.PORT, 3000, 'PORT'),
28
+ token: '',
29
+ }
30
+ }
31
+ const token = getArgument(argv, '--connection-token') || env.LVCE_REMOTE_SSH_CONNECTION_TOKEN || ''
32
+ if (!token) {
33
+ throw new TypeError('Remote SSH server requires --connection-token')
34
+ }
35
+ const port = parseInteger(getArgument(argv, '--port') || env.PORT, 0, '--port')
36
+ if (port < 0 || port > 65_535) {
37
+ throw new RangeError('--port must be between 0 and 65535')
38
+ }
39
+ const idleTimeout = parseInteger(getArgument(argv, '--idle-timeout') || env.LVCE_REMOTE_SSH_IDLE_TIMEOUT, defaultIdleTimeout, '--idle-timeout')
40
+ if (idleTimeout < 0) {
41
+ throw new RangeError('--idle-timeout must not be negative')
42
+ }
43
+ return {
44
+ enabled: true,
45
+ host: '127.0.0.1',
46
+ idleTimeout,
47
+ port,
48
+ token,
49
+ }
50
+ }
51
+
52
+ export const isAuthenticatedRemoteRequest = (request, options) => {
53
+ if (!options.enabled) {
54
+ return false
55
+ }
56
+ try {
57
+ const url = new URL(request.url || '/', 'http://127.0.0.1')
58
+ return url.searchParams.get('token') === options.token
59
+ } catch {
60
+ return false
61
+ }
62
+ }
package/src/server.js CHANGED
@@ -5,14 +5,13 @@ import { createServer } from 'node:http'
5
5
  import { dirname, join, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { Worker } from 'node:worker_threads'
8
+ import { getRemoteSshOptions, isAuthenticatedRemoteRequest } from './remoteSshOptions.js'
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url))
10
11
  const ROOT = resolve(__dirname, '../')
11
12
 
12
13
  const { argv, env } = process
13
14
 
14
- const PORT = env.PORT ? parseInt(env.PORT) : 3000
15
-
16
15
  let argv2 = argv[2]
17
16
 
18
17
  // TODO pass argv to shared process instead of using environment variables / global variables
@@ -30,6 +29,30 @@ if (!argv2) {
30
29
  }
31
30
 
32
31
  const isPublic = argv.includes('--public')
32
+ const remoteSshOptions = getRemoteSshOptions(argvSliced, env)
33
+ const PORT = remoteSshOptions.port
34
+ let remoteClientCount = 0
35
+ let remoteIdleTimer
36
+
37
+ const scheduleRemoteIdleShutdown = () => {
38
+ if (!remoteSshOptions.enabled || remoteClientCount !== 0) {
39
+ return
40
+ }
41
+ clearTimeout(remoteIdleTimer)
42
+ remoteIdleTimer = setTimeout(() => {
43
+ server.close(() => process.exit(0))
44
+ }, remoteSshOptions.idleTimeout)
45
+ remoteIdleTimer.unref()
46
+ }
47
+
48
+ const trackRemoteClient = (socket) => {
49
+ clearTimeout(remoteIdleTimer)
50
+ remoteClientCount++
51
+ socket.once('close', () => {
52
+ remoteClientCount--
53
+ scheduleRemoteIdleShutdown()
54
+ })
55
+ }
33
56
 
34
57
  /**
35
58
  * @enum {string}
@@ -49,7 +72,7 @@ const isStatic = (url) => {
49
72
  if (url === '/index.html' || url.startsWith('/index.html?')) {
50
73
  return true
51
74
  }
52
- if (url.startsWith('/6580ba2')) {
75
+ if (url.startsWith('/8afc276')) {
53
76
  return true
54
77
  }
55
78
  if (url.startsWith('/favicon.ico')) {
@@ -65,6 +88,11 @@ const isStatic = (url) => {
65
88
  }
66
89
 
67
90
  const handleRequest = (req, res) => {
91
+ if (remoteSshOptions.enabled) {
92
+ res.statusCode = 404
93
+ res.end()
94
+ return
95
+ }
68
96
  if (isStatic(req.url)) {
69
97
  return handleResponseViaStaticServer(req, res, 'StaticServer.getResponse')
70
98
  }
@@ -220,6 +248,7 @@ const getHandleMessage = (request) => {
220
248
  httpVersionMajor: request.httpVersionMajor,
221
249
  httpVersionMinor: request.httpVersionMinor,
222
250
  query: request.query,
251
+ remoteAuthorityAuthenticated: remoteSshOptions.enabled,
223
252
  }
224
253
  }
225
254
 
@@ -334,6 +363,13 @@ const handleResponseViaStaticServer = async (request, res, method, ...params) =>
334
363
  * @param {import('net').Socket} socket
335
364
  */
336
365
  const handleUpgrade = (request, socket) => {
366
+ if (remoteSshOptions.enabled && !isAuthenticatedRemoteRequest(request, remoteSshOptions)) {
367
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n')
368
+ return
369
+ }
370
+ if (remoteSshOptions.enabled) {
371
+ trackRemoteClient(socket)
372
+ }
337
373
  sendHandleSharedProcess(request, socket, 'HandleWebSocket.handleWebSocket')
338
374
  }
339
375
 
@@ -346,11 +382,16 @@ const handleServerError = (error) => {
346
382
  }
347
383
  }
348
384
 
385
+ const server = createServer(handleRequest)
386
+
349
387
  const handleAppReady = () => {
388
+ scheduleRemoteIdleShutdown()
350
389
  if (process.send) {
351
390
  process.send('ready')
352
391
  } else {
353
- console.info(`[server] listening on http://localhost:${PORT}`)
392
+ const address = server.address()
393
+ const port = typeof address === 'object' && address ? address.port : PORT
394
+ console.info(`[server] listening on http://${remoteSshOptions.host}:${port}`)
354
395
  }
355
396
  }
356
397
 
@@ -362,11 +403,10 @@ const handleUncaughtExceptionMonitor = (error, origin) => {
362
403
  const main = () => {
363
404
  process.on('message', handleMessageFromParent)
364
405
  process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor)
365
- const server = createServer(handleRequest)
366
406
  server.on('listening', handleAppReady)
367
407
  server.on('upgrade', handleUpgrade)
368
408
  server.on('error', handleServerError)
369
- const host = isPublic ? undefined : 'localhost'
409
+ const host = remoteSshOptions.enabled ? remoteSshOptions.host : isPublic ? undefined : remoteSshOptions.host
370
410
  server.listen(PORT, host)
371
411
  }
372
412
 
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { getRemoteSshOptions, isAuthenticatedRemoteRequest } from '../src/remoteSshOptions.js'
4
+
5
+ test('uses the regular server defaults without the remote flag', () => {
6
+ assert.deepEqual(getRemoteSshOptions([], {}), {
7
+ enabled: false,
8
+ host: 'localhost',
9
+ idleTimeout: 10_800_000,
10
+ port: 3000,
11
+ token: '',
12
+ })
13
+ })
14
+
15
+ test('parses private remote SSH server options', () => {
16
+ assert.deepEqual(getRemoteSshOptions(['--as-remote-ssh-server', '--port=45123', '--connection-token=secret', '--idle-timeout=25'], {}), {
17
+ enabled: true,
18
+ host: '127.0.0.1',
19
+ idleTimeout: 25,
20
+ port: 45123,
21
+ token: 'secret',
22
+ })
23
+ })
24
+
25
+ test('requires an authentication token in remote mode', () => {
26
+ assert.throws(() => getRemoteSshOptions(['--as-remote-ssh-server'], {}), /requires --connection-token/)
27
+ })
28
+
29
+ test('accepts only the configured query token', () => {
30
+ const options = getRemoteSshOptions(['--as-remote-ssh-server', '--connection-token=secret'], {})
31
+ assert.equal(isAuthenticatedRemoteRequest({ url: '/websocket/terminal-process?token=secret' }, options), true)
32
+ assert.equal(isAuthenticatedRemoteRequest({ url: '/websocket/terminal-process?token=wrong' }, options), false)
33
+ assert.equal(isAuthenticatedRemoteRequest({ url: '/websocket/terminal-process' }, options), false)
34
+ })
@@ -0,0 +1,132 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawn } from 'node:child_process'
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import test from 'node:test'
7
+
8
+ const serverPath = path.join(import.meta.dirname, '..', 'src', 'server.js')
9
+
10
+ const waitForListeningPort = (child) => {
11
+ return new Promise((resolve, reject) => {
12
+ let output = ''
13
+ const timeout = setTimeout(() => reject(new Error(`Timed out waiting for remote server\n${output}`)), 30_000)
14
+ const onData = (chunk) => {
15
+ output += chunk.toString('utf8')
16
+ const match = output.match(/listening on http:\/\/127\.0\.0\.1:(\d+)/)
17
+ if (match) {
18
+ clearTimeout(timeout)
19
+ child.stdout.off('data', onData)
20
+ resolve(Number.parseInt(match[1], 10))
21
+ }
22
+ }
23
+ child.stdout.on('data', onData)
24
+ child.stderr.on('data', (chunk) => {
25
+ output += chunk.toString('utf8')
26
+ })
27
+ child.once('error', reject)
28
+ child.once('exit', (code) => reject(new Error(`Remote server exited with code ${code}\n${output}`)))
29
+ })
30
+ }
31
+
32
+ const waitForOpen = (webSocket) => {
33
+ return new Promise((resolve, reject) => {
34
+ webSocket.onopen = resolve
35
+ webSocket.onerror = () => reject(new Error('WebSocket failed before opening'))
36
+ })
37
+ }
38
+
39
+ const waitForClose = (webSocket) => {
40
+ return new Promise((resolve) => {
41
+ webSocket.onclose = resolve
42
+ webSocket.onerror = () => {}
43
+ })
44
+ }
45
+
46
+ const createRpc = (webSocket) => {
47
+ let nextId = 1
48
+ const callbacks = new Map()
49
+ webSocket.onmessage = (event) => {
50
+ const response = JSON.parse(event.data)
51
+ const callback = callbacks.get(response.id)
52
+ if (callback) {
53
+ callbacks.delete(response.id)
54
+ callback(response)
55
+ }
56
+ }
57
+ return (method, ...params) => {
58
+ const id = nextId++
59
+ const promise = new Promise((resolve) => callbacks.set(id, resolve))
60
+ webSocket.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }))
61
+ return promise.then((response) => {
62
+ if (response.error) {
63
+ const error = new Error(response.error.message)
64
+ error.code = response.error.data?.code
65
+ throw error
66
+ }
67
+ return response.result
68
+ })
69
+ }
70
+ }
71
+
72
+ const stopProcessGroup = (child) => {
73
+ if (child.exitCode !== null || child.signalCode !== null) {
74
+ return
75
+ }
76
+ try {
77
+ process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGTERM')
78
+ } catch {
79
+ child.kill('SIGTERM')
80
+ }
81
+ }
82
+
83
+ test('remote mode authenticates and exposes existing workspace processes', { skip: typeof WebSocket === 'undefined' }, async (context) => {
84
+ const directory = await mkdtemp(path.join(tmpdir(), 'lvce-remote-backend-'))
85
+ const token = 'integration-secret'
86
+ const child = spawn(process.execPath, [serverPath, '--as-remote-ssh-server', '--port=0', `--connection-token=${token}`, '--idle-timeout=30000'], {
87
+ detached: true,
88
+ env: process.env,
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ })
91
+ context.after(async () => {
92
+ stopProcessGroup(child)
93
+ await rm(directory, { force: true, recursive: true })
94
+ })
95
+ const port = await waitForListeningPort(child)
96
+
97
+ const unauthorized = new WebSocket(`ws://127.0.0.1:${port}/websocket/file-system-process?token=wrong`)
98
+ await waitForClose(unauthorized)
99
+
100
+ const webSocket = new WebSocket(`ws://127.0.0.1:${port}/websocket/file-system-process?token=${token}`)
101
+ await waitForOpen(webSocket)
102
+ context.after(() => webSocket.close())
103
+ const invoke = createRpc(webSocket)
104
+
105
+ const toUri = (value) => {
106
+ const url = new URL('file:///')
107
+ url.pathname = value
108
+ return url.href
109
+ }
110
+ assert.equal(await invoke('FileSystem.stat', toUri(directory)), 3)
111
+ const folder = path.join(directory, 'folder')
112
+ const file = path.join(folder, 'file.txt')
113
+ await invoke('FileSystem.mkdir', toUri(folder))
114
+ await invoke('FileSystem.writeFile', toUri(file), Buffer.from('hello').toString('base64'), 'base64')
115
+ assert.equal(Buffer.from(await invoke('FileSystem.readFile', toUri(file), 'base64'), 'base64').toString('utf8'), 'hello')
116
+ assert.deepEqual(await invoke('FileSystem.readDirWithFileTypes', toUri(directory)), [{ name: 'folder', type: 3 }])
117
+ assert.equal(await readFile(file, 'utf8'), 'hello')
118
+ await invoke('FileSystem.forceRemove', toUri(folder))
119
+ await assert.rejects(readFile(file, 'utf8'), { code: 'ENOENT' })
120
+
121
+ if (process.platform !== 'win32') {
122
+ const processWebSocket = new WebSocket(`ws://127.0.0.1:${port}/websocket/process-explorer?token=${token}`)
123
+ await waitForOpen(processWebSocket)
124
+ context.after(() => processWebSocket.close())
125
+ const invokeProcessExplorer = createRpc(processWebSocket)
126
+ const rootPid = await invokeProcessExplorer('ProcessId.getMainProcessId', { includeElectronData: false })
127
+ assert.equal(Number.isSafeInteger(rootPid), true)
128
+ const processes = await invokeProcessExplorer('ListProcessesWithMemoryUsage.listProcessesWithMemoryUsage', rootPid, false)
129
+ assert.equal(Array.isArray(processes), true)
130
+ assert.equal(processes.length > 0, true)
131
+ }
132
+ })