tunli 0.0.19
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/LICENSE.md +595 -0
- package/README.md +135 -0
- package/bin/tunli +11 -0
- package/client.js +31 -0
- package/package.json +51 -0
- package/src/cli-app/Dashboard.js +146 -0
- package/src/cli-app/Screen.js +135 -0
- package/src/cli-app/elements/ElementNode.js +97 -0
- package/src/cli-app/elements/Line.js +21 -0
- package/src/cli-app/elements/List/List.js +227 -0
- package/src/cli-app/elements/List/ListCell.js +83 -0
- package/src/cli-app/elements/List/ListColumn.js +52 -0
- package/src/cli-app/elements/List/ListRow.js +118 -0
- package/src/cli-app/elements/Row.js +38 -0
- package/src/cli-app/helper/utils.js +42 -0
- package/src/commands/Action/addDelValuesAction.js +56 -0
- package/src/commands/CommandAuth.js +32 -0
- package/src/commands/CommandClearAll.js +27 -0
- package/src/commands/CommandConfig.js +57 -0
- package/src/commands/CommandHTTP.js +131 -0
- package/src/commands/CommandInvite.js +38 -0
- package/src/commands/CommandRefresh.js +35 -0
- package/src/commands/CommandRegister.js +48 -0
- package/src/commands/Option/DeleteOption.js +6 -0
- package/src/commands/Option/SelectConfigOption.js +52 -0
- package/src/commands/SubCommand/AllowDenyCidrCommand.js +28 -0
- package/src/commands/SubCommand/HostCommand.js +22 -0
- package/src/commands/SubCommand/PortCommand.js +20 -0
- package/src/commands/helper/AliasResolver.js +13 -0
- package/src/commands/helper/BindArgs.js +53 -0
- package/src/commands/helper/SharedArg.js +32 -0
- package/src/commands/utils.js +96 -0
- package/src/config/ConfigAbstract.js +318 -0
- package/src/config/ConfigManager.js +70 -0
- package/src/config/GlobalConfig.js +14 -0
- package/src/config/GlobalLocalShardConfigAbstract.js +76 -0
- package/src/config/LocalConfig.js +7 -0
- package/src/config/PropertyConfig.js +122 -0
- package/src/config/SystemConfig.js +31 -0
- package/src/core/FS/utils.js +60 -0
- package/src/core/Ref.js +70 -0
- package/src/lib/Flow/getCurrentIp.js +18 -0
- package/src/lib/Flow/getLatestVersion.js +13 -0
- package/src/lib/Flow/proxyUrl.js +32 -0
- package/src/lib/Flow/validateAuthToken.js +19 -0
- package/src/lib/HttpClient.js +61 -0
- package/src/lib/defs.js +10 -0
- package/src/net/IPV4.js +139 -0
- package/src/net/http/IncomingMessage.js +92 -0
- package/src/net/http/ServerResponse.js +126 -0
- package/src/net/http/TunliRequest.js +1 -0
- package/src/net/http/TunliResponse.js +1 -0
- package/src/net/http/TunnelRequest.js +177 -0
- package/src/net/http/TunnelResponse.js +119 -0
- package/src/tunnel-client/TunnelClient.js +136 -0
- package/src/utils/arrayFunctions.js +45 -0
- package/src/utils/checkFunctions.js +161 -0
- package/src/utils/cliFunctions.js +62 -0
- package/src/utils/createRequest.js +12 -0
- package/src/utils/httpFunction.js +23 -0
- package/src/utils/npmFunctions.js +27 -0
- package/src/utils/stringFunctions.js +34 -0
- package/types/index.d.ts +112 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Tunli
|
|
2
|
+
|
|
3
|
+
Tunli is a Node.js application that creates HTTP tunnels to make local software projects accessible over the internet.
|
|
4
|
+
This is particularly useful for developing and testing applications that need to be reachable from anywhere.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
To install Tunli, run the following command:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g tunli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Tunli can be used from the command line with various commands and options. Here are some of the key commands:
|
|
17
|
+
|
|
18
|
+
### Display Help
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
tunli --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Configure and View Settings
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
tunli config
|
|
28
|
+
tunli config host localhost
|
|
29
|
+
tunli config port 80
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Create HTTP Tunnel
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
tunli http [PORT] [HOST]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
tunli http localhost:80
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Registration and Authentication
|
|
45
|
+
|
|
46
|
+
To use Tunli, you need to register and authenticate.
|
|
47
|
+
|
|
48
|
+
#### Register
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
tunli register
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### Authenticate
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
tunli auth <TOKEN>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Create an Invitation
|
|
61
|
+
|
|
62
|
+
Generate a shareable registration token:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
tunli invite
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Examples
|
|
69
|
+
|
|
70
|
+
Here are some examples of how to use Tunli:
|
|
71
|
+
|
|
72
|
+
- Set the host for the local configuration:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
tunli config host localhost
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- Set the port for the local configuration:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
tunli config port 80
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- Show the local configuration:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
tunli config
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- Show the global configuration:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
tunli config --global
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- Forward HTTP requests to `localhost:80`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
tunli http localhost:80
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- Create a shareable registration token:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
tunli invite
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- Register this client with a given token:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
tunli auth <TOKEN>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Dependencies
|
|
115
|
+
|
|
116
|
+
Tunli relies on the following packages:
|
|
117
|
+
|
|
118
|
+
- `axios`: ^1.7.2
|
|
119
|
+
- `blessed`: ^0.1.81
|
|
120
|
+
- `chalk`: ^5.3.0
|
|
121
|
+
- `commander`: ^12.1.0
|
|
122
|
+
- `https-proxy-agent`: ^7.0.4
|
|
123
|
+
- `socket.io-client`: ^4.7.5
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
For development purposes, you can start the application using nodemon to automatically restart it on file changes:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm run dev
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
Tunli is licensed under the GPL-3.0 License.
|
package/bin/tunli
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
":" //# comment; exec /usr/bin/env node --harmony "$0" "$@"
|
|
3
|
+
import {proxyChildProcess, setCursorVisibility} from '#src/utils/cliFunctions'
|
|
4
|
+
import {exit} from 'node:process'
|
|
5
|
+
import {resolve} from "path";
|
|
6
|
+
import {dirnameFromMeta} from "#src/core/FS/utils";
|
|
7
|
+
|
|
8
|
+
setCursorVisibility(false)
|
|
9
|
+
const exitCode = await proxyChildProcess(resolve(dirnameFromMeta(import.meta), '../client.js'))
|
|
10
|
+
setCursorVisibility(true)
|
|
11
|
+
exit(exitCode)
|
package/client.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {dirnameFromMeta, readJsonFile, searchFileInDirectoryTree} from "#src/core/FS/utils";
|
|
2
|
+
import {program} from 'commander';
|
|
3
|
+
import {createCommandHTTP} from "#commands/CommandHTTP";
|
|
4
|
+
import {createCommandAuth} from "#commands/CommandAuth";
|
|
5
|
+
import {createCommandConfig} from "#commands/CommandConfig";
|
|
6
|
+
import {createCommandRegister} from "#commands/CommandRegister";
|
|
7
|
+
import {createCommandRefresh} from "#commands/CommandRefresh";
|
|
8
|
+
import {argumentAliasResolver} from "#commands/helper/AliasResolver";
|
|
9
|
+
import {createCommandInvite} from "#commands/CommandInvite";
|
|
10
|
+
import {addExamples, addUsage} from "#commands/utils";
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('tunli')
|
|
14
|
+
.description('HTTP tunnel client')
|
|
15
|
+
.option('-v, --version', 'version', () => {
|
|
16
|
+
const packageJson = readJsonFile(searchFileInDirectoryTree('package.json', dirnameFromMeta(import.meta)))
|
|
17
|
+
console.log(`tunli: ${packageJson.version}`)
|
|
18
|
+
process.exit()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
program.addCommand(createCommandConfig(program))
|
|
22
|
+
program.addCommand(createCommandHTTP(program), {isDefault: true})
|
|
23
|
+
program.addCommand(createCommandRegister(program))
|
|
24
|
+
program.addCommand(createCommandRefresh(program))
|
|
25
|
+
program.addCommand(createCommandInvite(program))
|
|
26
|
+
program.addCommand(createCommandAuth(program))
|
|
27
|
+
|
|
28
|
+
addExamples(program)
|
|
29
|
+
addUsage(program)
|
|
30
|
+
|
|
31
|
+
await program.parseAsync(argumentAliasResolver());
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tunli",
|
|
3
|
+
"description": "Node.js application for creating HTTP tunnels to make local software projects accessible over the internet.",
|
|
4
|
+
"version": "0.0.19",
|
|
5
|
+
"main": "bin/tunli",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tunli": "bin/tunli"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "nodemon client.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"HTTP tunnel",
|
|
14
|
+
"reverse proxy",
|
|
15
|
+
"remote access",
|
|
16
|
+
"local development",
|
|
17
|
+
"Node.js"
|
|
18
|
+
],
|
|
19
|
+
"author": "Pascal Pfeifer <pascal@tunli.app>",
|
|
20
|
+
"imports": {
|
|
21
|
+
"#lib/*": "./src/lib/*.js",
|
|
22
|
+
"#commands/*": "./src/commands/*.js",
|
|
23
|
+
"#src/*": "./src/*.js"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"axios": "^1.7.2",
|
|
28
|
+
"blessed": "^0.1.81",
|
|
29
|
+
"chalk": "^5.3.0",
|
|
30
|
+
"commander": "^12.1.0",
|
|
31
|
+
"https-proxy-agent": "^7.0.4",
|
|
32
|
+
"socket.io-client": "^4.7.5"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"nodemon": "^3.1.3"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/tunlijs/tunli-client"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"registry": "https://registry.npmjs.org"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://tunli.app",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"email": "dev@tunli.app",
|
|
47
|
+
"url": "https://github.com/tunlijs/tunli-client/issues"
|
|
48
|
+
},
|
|
49
|
+
"license": "GPL-3.0",
|
|
50
|
+
"readme": "README.md"
|
|
51
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {concat, createScreen} from "#src/cli-app/helper/utils";
|
|
2
|
+
import {dirnameFromMeta, readJsonFile, searchFileInDirectoryTree} from "#src/core/FS/utils";
|
|
3
|
+
import {ref} from "#src/core/Ref";
|
|
4
|
+
import {trimEnd} from "#src/utils/stringFunctions";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import {getLatestVersion} from "#lib/Flow/getLatestVersion";
|
|
7
|
+
import {exec} from 'child_process'
|
|
8
|
+
import {checkGlobalInstallation, checkLocalInstallation} from "#src/utils/npmFunctions";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
*
|
|
12
|
+
* @param {TunnelClient} client
|
|
13
|
+
* @param {tunnelClientOptions} options
|
|
14
|
+
* @param {AppConfig} config
|
|
15
|
+
*/
|
|
16
|
+
export const initDashboard = (client, options, config) => {
|
|
17
|
+
|
|
18
|
+
const screen = createScreen()
|
|
19
|
+
const packageJson = readJsonFile(searchFileInDirectoryTree('package.json', dirnameFromMeta(import.meta)))
|
|
20
|
+
const connectionStatus = ref(chalk.yellow('offline'))
|
|
21
|
+
const requestCount = ref(0)
|
|
22
|
+
const connectionDetails = ref('')
|
|
23
|
+
|
|
24
|
+
const allowedCidr = options.allowCidr.join(', ')
|
|
25
|
+
const deniedCidr = options.denyCidr.join(', ')
|
|
26
|
+
|
|
27
|
+
const blockedCount = ref(0)
|
|
28
|
+
const lastBlockedIp = ref('')
|
|
29
|
+
const availableUpdate = ref('')
|
|
30
|
+
|
|
31
|
+
getLatestVersion().then((version) => {
|
|
32
|
+
if (version && version !== packageJson.version) {
|
|
33
|
+
availableUpdate.value = chalk.yellow(`update available (version ${version}, Ctrl-U to update)`)
|
|
34
|
+
|
|
35
|
+
screen.onceKey('C-u', async (char, details) => {
|
|
36
|
+
availableUpdate.value = chalk.yellow('Updating...')
|
|
37
|
+
let modifier
|
|
38
|
+
if (await checkGlobalInstallation(packageJson.name)) {
|
|
39
|
+
modifier = ' -g'
|
|
40
|
+
} else if (!await checkLocalInstallation(packageJson.name)) {
|
|
41
|
+
availableUpdate.value = chalk.red('Update failed.')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const npmUpdateCommand = `npm${modifier} update ${packageJson.name} --registry https://registry.npmjs.org`
|
|
46
|
+
|
|
47
|
+
exec(npmUpdateCommand, (error, stdout, stderr) => {
|
|
48
|
+
if (error || stderr) {
|
|
49
|
+
availableUpdate.value = chalk.red('Update failed. Reason 2.')
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
availableUpdate.value = chalk.green('Update done. Please restart tunli. (Ctrl+R to restart)')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
screen.row(packageJson.name)
|
|
59
|
+
screen.row('(Ctrl+C to quit)', {
|
|
60
|
+
top: -1,
|
|
61
|
+
right: 0
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
screen.row("")
|
|
65
|
+
|
|
66
|
+
const infoList = screen.list({minWidth: 30})
|
|
67
|
+
|
|
68
|
+
screen.row('HTTP Requests')
|
|
69
|
+
screen.line()
|
|
70
|
+
|
|
71
|
+
const accessLog = screen.list({
|
|
72
|
+
length: 30,
|
|
73
|
+
reverse: true,
|
|
74
|
+
minWidth: [undefined, 30],
|
|
75
|
+
maxWidth: [undefined, screen.width - 35]
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
client.on('tunnel-connection-established', () => {
|
|
79
|
+
connectionStatus.value = chalk.bold(chalk.green('online'))
|
|
80
|
+
connectionDetails.value = ''
|
|
81
|
+
screen.render()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
client.on('blocked', ip => {
|
|
85
|
+
blockedCount.value++
|
|
86
|
+
lastBlockedIp.value = ` (${ip})`
|
|
87
|
+
})
|
|
88
|
+
client.on('tunnel-connection-closed', () => {
|
|
89
|
+
connectionStatus.value = chalk.bold(chalk.red('offline'))
|
|
90
|
+
screen.render()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
client.on('tunnel-connection-error', (error) => {
|
|
94
|
+
connectionDetails.value = chalk.bold(chalk.red(` - ${error.message}`))
|
|
95
|
+
screen.render()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
client.on('response', (res, req) => {
|
|
99
|
+
|
|
100
|
+
const code = res.statusCode
|
|
101
|
+
let rspMsg = `${res.statusCode} ${res.statusMessage}`
|
|
102
|
+
if (code >= 500) {
|
|
103
|
+
rspMsg = chalk.red(rspMsg)
|
|
104
|
+
} else if (code >= 400) {
|
|
105
|
+
rspMsg = chalk.blueBright(rspMsg)
|
|
106
|
+
} else {
|
|
107
|
+
rspMsg = chalk.green(rspMsg)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
rspMsg = chalk.bold(rspMsg)
|
|
111
|
+
|
|
112
|
+
requestCount.value++
|
|
113
|
+
accessLog.row(req.method, req.path, rspMsg)
|
|
114
|
+
screen.render()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
screen.key('C-c', (char, details) => {
|
|
118
|
+
process.exit(0);
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
screen.key('C-r', (char, details) => {
|
|
122
|
+
process.send('restart')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const target = new URL(`${options.protocol}://${options.host}:${options.port}`)
|
|
126
|
+
|
|
127
|
+
infoList.row('Tunnel', concat(connectionStatus, connectionDetails))
|
|
128
|
+
infoList.row('Version', packageJson.version)
|
|
129
|
+
infoList.row(chalk.yellow('Update'), availableUpdate).if(() => availableUpdate)
|
|
130
|
+
infoList.row('Profile', config.profile)
|
|
131
|
+
infoList.row('Config', config.configPath)
|
|
132
|
+
|
|
133
|
+
if (allowedCidr || deniedCidr) infoList.row('')
|
|
134
|
+
if (allowedCidr) infoList.row('Allowed', allowedCidr)
|
|
135
|
+
if (deniedCidr) infoList.row('Denied', deniedCidr)
|
|
136
|
+
if (allowedCidr || deniedCidr) infoList.row('Blocked', concat(blockedCount, lastBlockedIp))
|
|
137
|
+
|
|
138
|
+
infoList.row('')
|
|
139
|
+
infoList.row('Latency', concat(client.latency, 'ms'))
|
|
140
|
+
infoList.row('Forwarding', `${trimEnd(options.server, '/')} -> ${trimEnd(target.toString(), '/')}`)
|
|
141
|
+
infoList.row('Connections', requestCount)
|
|
142
|
+
|
|
143
|
+
screen.render();
|
|
144
|
+
|
|
145
|
+
return screen
|
|
146
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {List} from "#src/cli-app/elements/List/List";
|
|
2
|
+
import EventEmitter from "node:events";
|
|
3
|
+
import {Row} from "#src/cli-app/elements/Row";
|
|
4
|
+
import {ElementNode} from "#src/cli-app/elements/ElementNode";
|
|
5
|
+
import {arrayRemoveEntry} from "#src/utils/arrayFunctions";
|
|
6
|
+
import {Line} from "#src/cli-app/elements/Line";
|
|
7
|
+
|
|
8
|
+
export class Screen extends EventEmitter {
|
|
9
|
+
|
|
10
|
+
#screen
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @type {ElementNode[]}
|
|
14
|
+
*/
|
|
15
|
+
#elements = []
|
|
16
|
+
|
|
17
|
+
constructor(screen) {
|
|
18
|
+
super()
|
|
19
|
+
this.#screen = screen
|
|
20
|
+
|
|
21
|
+
for (const proxyFn of ['append']) {
|
|
22
|
+
this[proxyFn] = (...arg) => {
|
|
23
|
+
screen[proxyFn](...arg)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ele = new ElementNode(this)
|
|
28
|
+
ele._cached.height = ele.height
|
|
29
|
+
ele._cached.top = ele.positionTop
|
|
30
|
+
this.#elements.push(ele)
|
|
31
|
+
|
|
32
|
+
this.on('delete-element', (ele) => {
|
|
33
|
+
this.#elements = arrayRemoveEntry(this.#elements, ele)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
screen.on('keypress', (char, details) => this.emit('keypress', char, /** keypressEventDetails */ details))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get screen() {
|
|
40
|
+
return this.#screen
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get children() {
|
|
44
|
+
return this.#elements
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get width() {
|
|
48
|
+
return this.#screen.width
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get height() {
|
|
52
|
+
return this.#screen.height
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string|array} key
|
|
57
|
+
* @param {keypressEventListener} callback
|
|
58
|
+
*/
|
|
59
|
+
key(key, callback) {
|
|
60
|
+
this.#screen.key(key, callback)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string|array} key
|
|
65
|
+
* @param {keypressEventListener} callback
|
|
66
|
+
*/
|
|
67
|
+
onceKey(key, callback) {
|
|
68
|
+
this.#screen.onceKey(key, callback)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render() {
|
|
72
|
+
this.#screen.render()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
destroy() {
|
|
76
|
+
this.#screen.destroy()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
*
|
|
81
|
+
* @param {ElementNode} ele
|
|
82
|
+
*/
|
|
83
|
+
updateElement(ele) {
|
|
84
|
+
|
|
85
|
+
const heightChanged = ele._cached.height === ele.height
|
|
86
|
+
const topChanged = ele._cached.top === ele.positionTop
|
|
87
|
+
|
|
88
|
+
ele._cached.height = ele.height
|
|
89
|
+
ele._cached.top = ele.positionTop
|
|
90
|
+
|
|
91
|
+
if (heightChanged || topChanged) {
|
|
92
|
+
ele.nextElementSibling?.emit('prev-resize', ele)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {...cliListOption} [options]
|
|
98
|
+
* @returns {List}
|
|
99
|
+
*/
|
|
100
|
+
list(options) {
|
|
101
|
+
const ele = new List(this)
|
|
102
|
+
ele.init(options)
|
|
103
|
+
|
|
104
|
+
ele._cached.height = ele.height
|
|
105
|
+
ele._cached.top = ele.positionTop
|
|
106
|
+
|
|
107
|
+
this.#elements.push(ele)
|
|
108
|
+
return ele
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param content
|
|
113
|
+
* @param [top]
|
|
114
|
+
* @param [right]
|
|
115
|
+
* @returns {Row}
|
|
116
|
+
*/
|
|
117
|
+
row(content, {top, right} = {}) {
|
|
118
|
+
const ele = new Row(this)
|
|
119
|
+
ele.init(content, {top, right})
|
|
120
|
+
ele._cached.height = ele.height
|
|
121
|
+
ele._cached.top = ele.positionTop
|
|
122
|
+
|
|
123
|
+
this.#elements.push(ele)
|
|
124
|
+
return ele
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
line() {
|
|
128
|
+
const ele = new Line(this)
|
|
129
|
+
ele.init()
|
|
130
|
+
ele._cached.height = ele.height
|
|
131
|
+
ele._cached.top = ele.positionTop
|
|
132
|
+
this.#elements.push(ele)
|
|
133
|
+
return ele
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
|
|
3
|
+
export class ElementNode extends EventEmitter {
|
|
4
|
+
|
|
5
|
+
_cached = {
|
|
6
|
+
top: null,
|
|
7
|
+
height: null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @type {ElementNode}
|
|
12
|
+
*/
|
|
13
|
+
#nextElementSibling
|
|
14
|
+
/**
|
|
15
|
+
* @type {ElementNode}
|
|
16
|
+
*/
|
|
17
|
+
#prevElementSibling
|
|
18
|
+
/**
|
|
19
|
+
* @type {Screen}
|
|
20
|
+
*/
|
|
21
|
+
#screen
|
|
22
|
+
#height = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#detached = false
|
|
26
|
+
#offsetTop = 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Screen} screen
|
|
30
|
+
*/
|
|
31
|
+
constructor(screen) {
|
|
32
|
+
super()
|
|
33
|
+
this.#screen = screen
|
|
34
|
+
|
|
35
|
+
if (screen.children.length) {
|
|
36
|
+
const before = screen.children[screen.children.length - 1]
|
|
37
|
+
before.#nextElementSibling = this
|
|
38
|
+
this.#prevElementSibling = before
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* prev element postop and height
|
|
43
|
+
*/
|
|
44
|
+
this.on('prev-resize', ({positionTop, height}) => {
|
|
45
|
+
this.emit('position-top', positionTop + height)
|
|
46
|
+
this.nextElementSibling?.emit('prev-resize', this)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get detached() {
|
|
51
|
+
return this.#detached
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get nextElementSibling() {
|
|
55
|
+
return this.#nextElementSibling
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get positionTop() {
|
|
59
|
+
if (this.#prevElementSibling) {
|
|
60
|
+
return this.#prevElementSibling.positionTop + this.#prevElementSibling.height + this.#offsetTop
|
|
61
|
+
}
|
|
62
|
+
return this.#offsetTop
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
set offsetTop(offsetTop) {
|
|
66
|
+
this.#offsetTop = offsetTop ?? 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get screen() {
|
|
70
|
+
return this.#screen
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get height() {
|
|
74
|
+
return this.#height
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
set height(height) {
|
|
78
|
+
const update = this.#height !== height
|
|
79
|
+
this.#height = height
|
|
80
|
+
if (update) {
|
|
81
|
+
this.emit('height', height)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
delete() {
|
|
86
|
+
|
|
87
|
+
this.#screen.emit('delete-element', this)
|
|
88
|
+
this.emit('delete', this)
|
|
89
|
+
this.#detached = true
|
|
90
|
+
const next = this.#nextElementSibling
|
|
91
|
+
|
|
92
|
+
if (next) {
|
|
93
|
+
next.#prevElementSibling = this.#prevElementSibling
|
|
94
|
+
this.#nextElementSibling?.emit('prev-resize', this.#prevElementSibling)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {ElementNode} from "#src/cli-app/elements/ElementNode";
|
|
2
|
+
import blessed from "blessed";
|
|
3
|
+
|
|
4
|
+
export class Line extends ElementNode {
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {("horizontal", "vertical")} orientation
|
|
8
|
+
*/
|
|
9
|
+
init(orientation = 'horizontal') {
|
|
10
|
+
|
|
11
|
+
const line = blessed.line({
|
|
12
|
+
orientation
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
this.height = 1
|
|
16
|
+
this.on('position-top', (val) => line.top = val)
|
|
17
|
+
this.on('height', (val) => line.height = val)
|
|
18
|
+
this.on('delete', (val) => line.detach())
|
|
19
|
+
this.screen.append(line)
|
|
20
|
+
}
|
|
21
|
+
}
|