dsh-mesh-access 1.1.0 → 1.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/CHANGELOG.md +23 -0
- package/lib/client.js +99 -4
- package/lib/index.js +91 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 1.2.0 — 面板里就能填服务器信息
|
|
4
|
+
|
|
5
|
+
1.1.0 仍然要求你先去跑脚本写 `frpc.toml`,面板只是个看板。现在不用了:
|
|
6
|
+
|
|
7
|
+
- 「公网隧道」卡新增**改配置 / 填服务器信息**:服务器地址、`bindPort`、`remotePort`、本地端口、域名、token 直接在面板里填,保存后自动启动;
|
|
8
|
+
- token 留空表示沿用 `frpc.toml` 里已有的那个(不用每次重填,也不会把密钥显示在页面上);
|
|
9
|
+
- 填了域名,复制的地址就是 **`https://域名/?token=…`**(域名记在同目录的 `dsh-mesh-access.json` 侧车里,`frpc.toml` 保持标准 frp 格式);
|
|
10
|
+
- 输入校验:地址只认 IP/域名、端口必须 1–65535、域名格式检查,错误直接在面板上说明;
|
|
11
|
+
- 面板写出来的 `frpc.toml` 和 `tools/frpc-setup.sh` 生成的是同一种文件,先跑脚本后来用面板改也不会打架。
|
|
12
|
+
|
|
13
|
+
仍然要**手动做一次**的事(脚本和面板都替你做不了):DNS 里给域名加 A 记录指向服务器、云安全组放行端口、
|
|
14
|
+
在服务器上跑一次 `tools/frps-setup.sh`(它会顺带把证书和 Basic 认证配好)。
|
|
15
|
+
|
|
16
|
+
## 1.1.1 — 面板把「外部进程」说清楚
|
|
17
|
+
|
|
18
|
+
1.1.0 里如果 frpc 是你自己的 systemd 单元在跑,面板会同时显示「frpc 未运行」和「公网可达」,看着自相矛盾。改成:
|
|
19
|
+
|
|
20
|
+
- 第一颗状态改成三态:`frpc 运行中(本插件)` / `frpc 运行中(外部进程)` / `frpc 未运行`;
|
|
21
|
+
- 「停止」按钮在外部进程时禁用并给出提示,告诉你用 `systemctl --user stop dsh-frpc` 停;
|
|
22
|
+
- 说明文字写清为什么不让重复启动(两个 frpc 抢同一个 `remotePort`)。
|
|
23
|
+
|
|
24
|
+
功能没有变化。
|
|
25
|
+
|
|
3
26
|
## 1.1.0 — 把「自己的 VPS 隧道」做进面板
|
|
4
27
|
|
|
5
28
|
组网这条路在某些网络下注定要中继(校园网只能一台设备在线、手机蜂窝 CGNAT),TCP 中继 RTT 800–990 ms,长连接撑不住。所以面板多了一张「公网隧道」卡片,用你自己的服务器 + frp 走第二条路:
|
package/lib/client.js
CHANGED
|
@@ -41,6 +41,16 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
dot: (on) => ({ width: 7, height: 7, borderRadius: 999, background: on ? '#3fb950' : '#8b949e' }),
|
|
42
42
|
err: { color: '#f85149', fontSize: 13, marginTop: 8 },
|
|
43
43
|
hint: { fontSize: 12, opacity: 0.6, marginTop: 10, lineHeight: 1.6 },
|
|
44
|
+
input: {
|
|
45
|
+
padding: '6px 10px',
|
|
46
|
+
borderRadius: 8,
|
|
47
|
+
border: '1px solid rgba(127,127,127,.35)',
|
|
48
|
+
background: 'transparent',
|
|
49
|
+
color: 'inherit',
|
|
50
|
+
fontSize: 13,
|
|
51
|
+
width: 200,
|
|
52
|
+
},
|
|
53
|
+
label: { fontSize: 12, opacity: 0.7, marginBottom: 4, display: 'block' },
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
/** 带硬超时的 fetch:超时就 abort,绝不让调用方永远挂着。
|
|
@@ -61,7 +71,7 @@ window.__ModuleLoader__.load({
|
|
|
61
71
|
fetchJson('/api/dsh-mesh-access/tunnel', {
|
|
62
72
|
method: 'POST',
|
|
63
73
|
headers: { 'content-type': 'application/json' },
|
|
64
|
-
body: JSON.stringify({ action }),
|
|
74
|
+
body: JSON.stringify(typeof action === 'string' ? { action } : action),
|
|
65
75
|
}, 20000),
|
|
66
76
|
action: (action, port) =>
|
|
67
77
|
fetchJson('/api/dsh-mesh-access/proxy', {
|
|
@@ -92,6 +102,8 @@ window.__ModuleLoader__.load({
|
|
|
92
102
|
const [err, setErr] = react.useState(null)
|
|
93
103
|
const [tunnelErr, setTunnelErr] = react.useState(null)
|
|
94
104
|
const [copied, setCopied] = react.useState(false)
|
|
105
|
+
const [formOpen, setFormOpen] = react.useState(false)
|
|
106
|
+
const [form, setForm] = react.useState({})
|
|
95
107
|
|
|
96
108
|
const refresh = react.useCallback(() => {
|
|
97
109
|
api
|
|
@@ -128,6 +140,49 @@ window.__ModuleLoader__.load({
|
|
|
128
140
|
.finally(() => setTunnelBusy(false))
|
|
129
141
|
}
|
|
130
142
|
|
|
143
|
+
const runConfig = () => {
|
|
144
|
+
const payload = Object.assign({ action: 'config' }, form)
|
|
145
|
+
setTunnelBusy(true)
|
|
146
|
+
setTunnelErr(null)
|
|
147
|
+
api
|
|
148
|
+
.tunnelAction(payload)
|
|
149
|
+
.then((t) => {
|
|
150
|
+
if (t && t.ok === false) {
|
|
151
|
+
setTunnelErr(t.error ?? '保存失败')
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
setFormOpen(false)
|
|
155
|
+
setForm({})
|
|
156
|
+
refresh()
|
|
157
|
+
// 存完顺手起隧道;起不来也只报错,不静默
|
|
158
|
+
return api.tunnelAction('start')
|
|
159
|
+
})
|
|
160
|
+
.then((t) => {
|
|
161
|
+
if (!t) return
|
|
162
|
+
if (t && t.ok === false) setTunnelErr(t.error ?? '隧道没起来')
|
|
163
|
+
else if (t && t.error) setTunnelErr(t.error)
|
|
164
|
+
refresh()
|
|
165
|
+
})
|
|
166
|
+
.catch((e) => setTunnelErr(String(e && e.message ? e.message : e)))
|
|
167
|
+
.finally(() => setTunnelBusy(false))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const openForm = () => {
|
|
171
|
+
const tn0 = tunnel || (state && state.tunnel) || {}
|
|
172
|
+
setForm({
|
|
173
|
+
serverAddr: tn0.serverAddr || '',
|
|
174
|
+
serverPort: String(tn0.serverPort || 7000),
|
|
175
|
+
remotePort: String(tn0.remotePort || 21081),
|
|
176
|
+
localPort: String(tn0.localPort || (state && state.proxy && state.proxy.port) || 3081),
|
|
177
|
+
localIP: tn0.localIP || (state && state.mesh && state.mesh[0] && state.mesh[0].address) || '127.0.0.1',
|
|
178
|
+
domain: tn0.domain || '',
|
|
179
|
+
token: '',
|
|
180
|
+
})
|
|
181
|
+
setFormOpen(true)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const setField = (k) => (e) => setForm(Object.assign({}, form, { [k]: e.target.value }))
|
|
185
|
+
|
|
131
186
|
const run = (action) => {
|
|
132
187
|
setBusy(true)
|
|
133
188
|
setErr(null)
|
|
@@ -215,7 +270,8 @@ window.__ModuleLoader__.load({
|
|
|
215
270
|
h(
|
|
216
271
|
Card, { title: '公网隧道(自己的 VPS,可选的第二条路)' },
|
|
217
272
|
h('div', { style: S.row },
|
|
218
|
-
h('span', { style: S.pill(Boolean(tn.running)) }, h('span', { style: S.dot(Boolean(tn.running
|
|
273
|
+
h('span', { style: S.pill(Boolean(tn.running || tn.external)) }, h('span', { style: S.dot(Boolean(tn.running || tn.external)) }),
|
|
274
|
+
tn.running ? 'frpc 运行中(本插件)' : tn.external ? 'frpc 运行中(外部进程)' : 'frpc 未运行'),
|
|
219
275
|
h('span', { style: S.pill(Boolean(tn.reachable)) }, h('span', { style: S.dot(Boolean(tn.reachable)) }), tn.reachable ? '公网可达' : '公网不可达'),
|
|
220
276
|
h('span', { style: S.pill(Boolean(tn.url)) }, h('span', { style: S.dot(Boolean(tn.url)) }), tn.domain ? '域名 ' + tn.domain : tn.remotePort ? '端口 ' + tn.remotePort : '未配置'),
|
|
221
277
|
),
|
|
@@ -225,9 +281,48 @@ window.__ModuleLoader__.load({
|
|
|
225
281
|
disabled: tunnelBusy || Boolean(tn.running) || Boolean(tn.external) || !tn.configFound,
|
|
226
282
|
onClick: () => runTunnel('start'),
|
|
227
283
|
}, tunnelBusy ? '处理中…' : tn.external ? '外部进程已在跑' : '启动隧道'),
|
|
228
|
-
h('button', {
|
|
284
|
+
h('button', {
|
|
285
|
+
style: S.btn,
|
|
286
|
+
disabled: tunnelBusy || !tn.running,
|
|
287
|
+
title: tn.external && !tn.running ? '这个 frpc 是外部进程(systemd 单元),请用 systemctl --user stop dsh-frpc' : '',
|
|
288
|
+
onClick: () => runTunnel('stop'),
|
|
289
|
+
}, '停止'),
|
|
229
290
|
h('button', { style: S.btn, disabled: tunnelBusy, onClick: refresh }, '刷新'),
|
|
291
|
+
h('button', { style: S.btn, disabled: tunnelBusy, onClick: formOpen ? () => setFormOpen(false) : openForm }, formOpen ? '收起' : tn.configFound ? '改配置' : '填服务器信息'),
|
|
230
292
|
),
|
|
293
|
+
formOpen
|
|
294
|
+
? h('div', { style: { marginTop: 12, padding: 12, borderRadius: 8, border: '1px dashed rgba(127,127,127,.35)' } },
|
|
295
|
+
h('div', { style: S.hint, marginTop: 0, opacity: 0.8 },
|
|
296
|
+
'填的是你自己的服务器。不知道填什么就问给你配服务器的那个人,或者把 docs/AI-SETUP.md 丢给你的 AI。'),
|
|
297
|
+
h('div', { style: S.row, marginTop: 10, gap: 10 },
|
|
298
|
+
h('div', null, h('label', { style: S.label }, '服务器地址(IP 或域名)'),
|
|
299
|
+
h('input', { style: S.input, value: form.serverAddr || '', placeholder: '1.2.3.4', onChange: setField('serverAddr') })),
|
|
300
|
+
h('div', null, h('label', { style: S.label }, '绑定端口 bindPort'),
|
|
301
|
+
h('input', { style: S.input, value: form.serverPort || '', placeholder: '7000', onChange: setField('serverPort') })),
|
|
302
|
+
h('div', null, h('label', { style: S.label }, '公网端口 remotePort'),
|
|
303
|
+
h('input', { style: S.input, value: form.remotePort || '', placeholder: '21081', onChange: setField('remotePort') })),
|
|
304
|
+
),
|
|
305
|
+
h('div', { style: S.row, marginTop: 10, gap: 10 },
|
|
306
|
+
h('div', null, h('label', { style: S.label }, '域名(可选,填了就用 https)'),
|
|
307
|
+
h('input', { style: S.input, value: form.domain || '', placeholder: 'dsh.example.com', onChange: setField('domain') })),
|
|
308
|
+
h('div', null, h('label', { style: S.label }, '本地端口(组网 IP 上那个)'),
|
|
309
|
+
h('input', { style: S.input, value: form.localPort || '', placeholder: '3081', onChange: setField('localPort') })),
|
|
310
|
+
h('div', null, h('label', { style: S.label }, 'token' + (tn.tokenSet ? '(已存,留空不变)' : '')),
|
|
311
|
+
h('input', {
|
|
312
|
+
style: S.input,
|
|
313
|
+
type: 'password',
|
|
314
|
+
value: form.token || '',
|
|
315
|
+
placeholder: tn.tokenSet ? '••••••••(保持不变)' : '服务端 /etc/frp/frps.toml 里的 auth.token',
|
|
316
|
+
onChange: setField('token'),
|
|
317
|
+
})),
|
|
318
|
+
),
|
|
319
|
+
h('div', { style: S.row, marginTop: 12 },
|
|
320
|
+
h('button', { style: S.btnPrimary, disabled: tunnelBusy, onClick: runConfig }, tunnelBusy ? '保存中…' : '保存并启动'),
|
|
321
|
+
h('button', { style: S.btn, disabled: tunnelBusy, onClick: () => setFormOpen(false) }, '取消'),
|
|
322
|
+
),
|
|
323
|
+
h('div', { style: S.hint }, '保存会写 ~/.config/frp/frpc.toml(600)和同目录的 dsh-mesh-access.json(只存域名)。'),
|
|
324
|
+
)
|
|
325
|
+
: null,
|
|
231
326
|
tunnelErr ? h('div', { style: S.err }, tunnelErr) : null,
|
|
232
327
|
tn.urlSigned
|
|
233
328
|
? h('div', { style: S.row, marginTop: 12 },
|
|
@@ -240,7 +335,7 @@ window.__ModuleLoader__.load({
|
|
|
240
335
|
? 'frpc 配置:' + tn.configPath + (tn.serverAddr ? '(' + tn.serverAddr + ',本地 ' + tn.localPort + ' → 远端 ' + tn.remotePort + ')' : '')
|
|
241
336
|
: '没找到 frpc 配置 ' + (tn.configPath || '') + '。这条是可选的:用你自己的服务器做公网入口,就不受组网中继心跳超时的影响。装法见 docs/VPS.md,或者把 docs/AI-SETUP.md 丢给你的 AI 让它跑 tools/frps-setup.sh + tools/frpc-setup.sh。',
|
|
242
337
|
tn.external && !tn.running
|
|
243
|
-
? '
|
|
338
|
+
? ' 现在这个隧道是你自己的外部进程在跑(多半是 systemd 单元 dsh-frpc),所以这里显示为「外部进程」并且不让重复启动,免得抢同一个 remotePort。想停:systemctl --user stop dsh-frpc。'
|
|
244
339
|
: '',
|
|
245
340
|
' 组网走中继时 RTT 800–990 ms(最坏 3.7 s),长连接会被心跳判死;你自己的服务器通常 30–60 ms。',
|
|
246
341
|
),
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import os from 'node:os'
|
|
|
19
19
|
import fs from 'node:fs'
|
|
20
20
|
import http from 'node:http'
|
|
21
21
|
import net from 'node:net'
|
|
22
|
+
import path from 'node:path'
|
|
22
23
|
import { spawn } from 'node:child_process'
|
|
23
24
|
|
|
24
25
|
export const name = 'dsh-mesh-access'
|
|
@@ -187,6 +188,7 @@ export function apply(ctx, config = {}) {
|
|
|
187
188
|
// 公网隧道(frp)。默认路径与 frp 官网安装脚本一致,用户换位置就在配置里覆盖。
|
|
188
189
|
frpcBin: asText(config.frpcBin, `${os.homedir()}/.local/bin/frpc`),
|
|
189
190
|
frpcConfig: asText(config.frpcConfig, `${os.homedir()}/.config/frp/frpc.toml`),
|
|
191
|
+
frpcLog: asText(config.frpcLog, `${os.homedir()}/.config/frp/frpc.log`),
|
|
190
192
|
// 隧道对外暴露的域名(有 TLS 时写域名,没有就留空 —— 会自动退回 http://serverAddr:remotePort)
|
|
191
193
|
tunnelDomain: asText(config.tunnelDomain, ''),
|
|
192
194
|
}
|
|
@@ -260,6 +262,83 @@ export function apply(ctx, config = {}) {
|
|
|
260
262
|
return Number.isFinite(num) ? num : raw
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
const sidecarPath = () => `${path.dirname(cfg.frpcConfig)}/dsh-mesh-access.json`
|
|
266
|
+
|
|
267
|
+
const readSidecar = () => {
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(fs.readFileSync(sidecarPath(), 'utf8'))
|
|
270
|
+
} catch {
|
|
271
|
+
return {}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const writeSidecar = (data) => {
|
|
276
|
+
try {
|
|
277
|
+
fs.mkdirSync(path.dirname(sidecarPath()), { recursive: true })
|
|
278
|
+
fs.writeFileSync(sidecarPath(), JSON.stringify(data, null, 2) + '\n')
|
|
279
|
+
} catch {
|
|
280
|
+
/* 侧车只是记域名,写不进去就退回插件配置 */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 写 frpc.toml + 侧车(域名)。面板上的「设置」和 tools/frpc-setup.sh 产出的是同一种文件,
|
|
286
|
+
* 所以用户先跑脚本、后来改用面板改,两边不会打架。
|
|
287
|
+
*/
|
|
288
|
+
const writeTunnelConfig = (input) => {
|
|
289
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '')
|
|
290
|
+
const serverAddr = str(input.serverAddr)
|
|
291
|
+
const domain = str(input.domain).replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
|
292
|
+
const serverPort = Number(input.serverPort ?? 7000)
|
|
293
|
+
const remotePort = Number(input.remotePort ?? 21081)
|
|
294
|
+
const localPort = Number(input.localPort ?? cfg.proxyPort)
|
|
295
|
+
const localIP = str(input.localIP) || '127.0.0.1'
|
|
296
|
+
const token = str(input.token)
|
|
297
|
+
|
|
298
|
+
if (!/^[A-Za-z0-9._-]+$/.test(serverAddr)) throw new Error('服务器地址只能填 IP 或域名')
|
|
299
|
+
for (const [name, v] of [['绑定端口', serverPort], ['公网端口', remotePort], ['本地端口', localPort]]) {
|
|
300
|
+
if (!Number.isInteger(v) || v < 1 || v > 65535) throw new Error(`${name}要填 1–65535 之间的整数`)
|
|
301
|
+
}
|
|
302
|
+
if (domain && !/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(domain)) throw new Error('域名格式不对,例如 dsh.example.com')
|
|
303
|
+
|
|
304
|
+
let oldToken = ''
|
|
305
|
+
try {
|
|
306
|
+
oldToken = String(tomlValue(fs.readFileSync(cfg.frpcConfig, 'utf8'), 'auth.token') || '')
|
|
307
|
+
} catch {
|
|
308
|
+
/* 没有旧配置就算了 */
|
|
309
|
+
}
|
|
310
|
+
const finalToken = token || oldToken
|
|
311
|
+
if (!finalToken) throw new Error('token 不能为空(服务端 /etc/frp/frps.toml 里的 auth.token)')
|
|
312
|
+
|
|
313
|
+
const text = `# 由 dsh-mesh-access 面板生成
|
|
314
|
+
serverAddr = "${serverAddr}"
|
|
315
|
+
serverPort = ${serverPort}
|
|
316
|
+
|
|
317
|
+
auth.method = "token"
|
|
318
|
+
auth.token = "${finalToken}"
|
|
319
|
+
|
|
320
|
+
log.to = "${cfg.frpcLog}"
|
|
321
|
+
log.level = "info"
|
|
322
|
+
|
|
323
|
+
[[proxies]]
|
|
324
|
+
name = "dsh-mesh"
|
|
325
|
+
type = "tcp"
|
|
326
|
+
localIP = "${localIP}"
|
|
327
|
+
localPort = ${localPort}
|
|
328
|
+
remotePort = ${remotePort}
|
|
329
|
+
transport.useEncryption = true
|
|
330
|
+
`
|
|
331
|
+
fs.mkdirSync(path.dirname(cfg.frpcConfig), { recursive: true })
|
|
332
|
+
fs.writeFileSync(cfg.frpcConfig, text, { mode: 0o600 })
|
|
333
|
+
try {
|
|
334
|
+
fs.chmodSync(cfg.frpcConfig, 0o600)
|
|
335
|
+
} catch {
|
|
336
|
+
/* ignore */
|
|
337
|
+
}
|
|
338
|
+
writeSidecar({ domain: domain || null, updatedAt: new Date().toISOString() })
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
263
342
|
const tunnelCfg = () => {
|
|
264
343
|
let text = ''
|
|
265
344
|
try {
|
|
@@ -271,7 +350,8 @@ export function apply(ctx, config = {}) {
|
|
|
271
350
|
const serverPort = Number(tomlValue(text, 'serverPort') ?? 7000)
|
|
272
351
|
const remotePort = Number(tomlValue(text, 'remotePort') ?? 0)
|
|
273
352
|
const localPort = Number(tomlValue(text, 'localPort') ?? cfg.proxyPort)
|
|
274
|
-
const
|
|
353
|
+
const localIP = String(tomlValue(text, 'localIP') || '127.0.0.1')
|
|
354
|
+
const domain = readSidecar().domain || cfg.tunnelDomain || ''
|
|
275
355
|
// 有域名就走 https://domain/(VPS 上 nginx 已经接管 TLS),否则直连 IP:remotePort
|
|
276
356
|
const base = domain ? `https://${domain}/` : remotePort && serverAddr ? `http://${serverAddr}:${remotePort}/` : null
|
|
277
357
|
return {
|
|
@@ -282,6 +362,8 @@ export function apply(ctx, config = {}) {
|
|
|
282
362
|
serverPort,
|
|
283
363
|
remotePort,
|
|
284
364
|
localPort,
|
|
365
|
+
localIP,
|
|
366
|
+
tokenSet: Boolean(tomlValue(text, 'auth.token')),
|
|
285
367
|
domain: domain || null,
|
|
286
368
|
url: base,
|
|
287
369
|
urlSigned: signed(base),
|
|
@@ -558,6 +640,14 @@ export function apply(ctx, config = {}) {
|
|
|
558
640
|
const action = String(body.action ?? '')
|
|
559
641
|
if (action === 'start') return json(res, 200, await startTunnel())
|
|
560
642
|
if (action === 'stop') return json(res, 200, await stopTunnel())
|
|
643
|
+
if (action === 'config') {
|
|
644
|
+
try {
|
|
645
|
+
writeTunnelConfig(body)
|
|
646
|
+
} catch (err) {
|
|
647
|
+
return json(res, 400, { ok: false, error: String(err?.message ?? err) })
|
|
648
|
+
}
|
|
649
|
+
return json(res, 200, { ok: true, ...tunnelSnapshot() })
|
|
650
|
+
}
|
|
561
651
|
return json(res, 400, { ok: false, error: "action 只能是 'start' 或 'stop'" })
|
|
562
652
|
},
|
|
563
653
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mesh-access",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Control your DSH from your phone: opens a port on the mesh/VPN interface from inside the running server process, so a phone browser gets the same instance and the same session list — no SSH, no second dsh. Also reachable from another PC, a GUI NAS, or a TV box.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|