@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.
package/ThirdPartyNotices.txt
CHANGED
|
@@ -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/
|
|
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/
|
|
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.
|
|
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.
|
|
24
|
-
"@lvce-editor/static-server": "0.
|
|
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('/
|
|
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
|
-
|
|
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 :
|
|
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
|
+
})
|