project-foundry 0.0.0-stage → 1.0.1
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 +40 -2
- package/bin/foundry.js +153 -0
- package/package.json +23 -4
- package/src/api-client.js +76 -0
- package/src/config.js +70 -0
- package/src/daemon.js +156 -0
- package/src/service-manager.js +74 -0
- package/src/tui.js +118 -0
- package/src/tunnel-client.js +160 -0
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Project Foundry CLI & Edge Node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Gerenciador local e agente de farm para o [Project Foundry](https://foundry.projectcore.site).
|
|
4
|
+
|
|
5
|
+
Permite conectar qualquer máquina local (Orange Pi, Raspberry Pi, Mini PC ou computador Windows) posicionada na rede das impressoras 3D ao painel de controle na nuvem com um único comando.
|
|
6
|
+
|
|
7
|
+
## Instalação e Pareamento
|
|
8
|
+
|
|
9
|
+
No painel do Project Foundry (Configurações → Servidores Locais), clique em **Adicionar Servidor** para gerar seu token de pareamento. Em seguida, execute no terminal da sua máquina local:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx project-foundry setup --token <seu-token>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Para direcionar os arquivos pesados (G-codes, 3MFs e timelapses) diretamente para um HD externo conectado:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx project-foundry setup --token <seu-token> --storage /mnt/hd1tb/storage
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Recursos
|
|
22
|
+
|
|
23
|
+
- **Túnel WebSocket Reverso**: Conexão segura de saída para a nuvem sem abrir portas no roteador da empresa.
|
|
24
|
+
- **Suporte a HD Externo**: Salva arquivos 3D e timelapses no disco que você escolher.
|
|
25
|
+
- **Inicialização Automática**: Cria serviço do sistema (`systemd` no Linux ou Tarefa Agendada no Windows) para reiniciar automaticamente após quedas de energia.
|
|
26
|
+
- **Terminal UI (TUI)**: Menu interativo com opção de rodar em segundo plano (`Hide to Tray`) com zero risco de desligamento acidental.
|
|
27
|
+
- **Ponte OrcaSlicer e Bambu Studio**: Recepção de fatiamentos direto na LAN em alta velocidade.
|
|
28
|
+
|
|
29
|
+
## Uso
|
|
30
|
+
|
|
31
|
+
Para reabrir o menu de controle no terminal a qualquer momento:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx project-foundry
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Ou se instalado globalmente (`npm install -g project-foundry`):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
foundry
|
|
41
|
+
```
|
package/bin/foundry.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
import { hasConfig, writeConfig, readConfig, getDefaultStorageDir } from '../src/config.js';
|
|
8
|
+
import { registerNodeWithCloud } from '../src/api-client.js';
|
|
9
|
+
import { installAutostartService, isServerHealthy, startDaemonBackground } from '../src/service-manager.js';
|
|
10
|
+
import { runInteractiveTui } from '../src/tui.js';
|
|
11
|
+
import { startDaemon } from '../src/daemon.js';
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('foundry')
|
|
17
|
+
.description('Project Foundry CLI & Edge Node Manager')
|
|
18
|
+
.version('1.0.0');
|
|
19
|
+
|
|
20
|
+
// Comando: daemon (segundo plano)
|
|
21
|
+
program
|
|
22
|
+
.command('daemon')
|
|
23
|
+
.description('Inicia o servidor e o túnel reverso em segundo plano')
|
|
24
|
+
.action(() => {
|
|
25
|
+
startDaemon();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Comando: setup
|
|
29
|
+
program
|
|
30
|
+
.command('setup')
|
|
31
|
+
.description('Configura e pareia o servidor local com a conta do Project Foundry')
|
|
32
|
+
.requiredOption('-t, --token <token>', 'Token de pareamento descartável gerado na nuvem')
|
|
33
|
+
.option('-s, --server <url>', 'URL do servidor Cloud', 'https://foundry.projectcore.site')
|
|
34
|
+
.option('-n, --name <name>', 'Nome descritivo deste servidor')
|
|
35
|
+
.option('--storage <path>', 'Caminho do diretório de armazenamento (ex: HD externo)')
|
|
36
|
+
.action(async (options) => {
|
|
37
|
+
p.intro(pc.bold(pc.cyan('Configuração do Servidor Local Project Foundry')));
|
|
38
|
+
|
|
39
|
+
const s = p.spinner();
|
|
40
|
+
s.start('Conectando à nuvem e validando token de pareamento...');
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const regData = await registerNodeWithCloud({
|
|
44
|
+
serverUrl: options.server,
|
|
45
|
+
token: options.token,
|
|
46
|
+
name: options.name,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
s.stop(pc.green('Pareamento realizado com sucesso!'));
|
|
50
|
+
|
|
51
|
+
// Configuração de Armazenamento (HD Externo ou Padrão)
|
|
52
|
+
let storagePath = options.storage;
|
|
53
|
+
if (!storagePath) {
|
|
54
|
+
const storageChoice = await p.select({
|
|
55
|
+
message: 'Onde deseja salvar os arquivos 3D (3MF/G-code) e timelapses?',
|
|
56
|
+
options: [
|
|
57
|
+
{
|
|
58
|
+
value: 'default',
|
|
59
|
+
label: 'Pasta padrão do sistema',
|
|
60
|
+
hint: `(${getDefaultStorageDir()})`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
value: 'custom',
|
|
64
|
+
label: 'HD Externo / Diretório Personalizado',
|
|
65
|
+
hint: process.platform === 'win32' ? 'Ex: D:\\Storage' : 'Ex: /mnt/hd1tb/storage',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (storageChoice === 'custom') {
|
|
71
|
+
const customInput = await p.text({
|
|
72
|
+
message: 'Digite o caminho completo da pasta no seu HD/Disco:',
|
|
73
|
+
placeholder: process.platform === 'win32' ? 'D:\\Storage' : '/mnt/hd1tb/storage',
|
|
74
|
+
validate: (val) => {
|
|
75
|
+
if (!val || !val.trim()) return 'O caminho é obrigatório';
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (!p.isCancel(customInput)) {
|
|
79
|
+
storagePath = customInput.trim();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const finalStorage = storagePath ? path.resolve(storagePath) : getDefaultStorageDir();
|
|
85
|
+
|
|
86
|
+
// Salva configuração local
|
|
87
|
+
writeConfig({
|
|
88
|
+
nodeId: regData.nodeId,
|
|
89
|
+
nodeName: regData.nodeName,
|
|
90
|
+
organizationId: regData.organizationId,
|
|
91
|
+
apiKey: regData.apiKey,
|
|
92
|
+
serverUrl: regData.serverUrl,
|
|
93
|
+
storagePath: finalStorage,
|
|
94
|
+
localPort: 3010,
|
|
95
|
+
configuredAt: new Date().toISOString(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
p.log.success(`Servidor vinculado à organização: ${pc.bold(regData.organizationId)}`);
|
|
99
|
+
p.log.success(`Armazenamento configurado em: ${pc.bold(finalStorage)}`);
|
|
100
|
+
|
|
101
|
+
// Configuração de serviço
|
|
102
|
+
const installService = await p.confirm({
|
|
103
|
+
message: 'Deseja configurar inicialização automática com o sistema operacional?',
|
|
104
|
+
initialValue: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (installService && !p.isCancel(installService)) {
|
|
108
|
+
s.start('Registrando serviço de autostart no sistema operacional...');
|
|
109
|
+
const serviceRes = installAutostartService(process.argv[1]);
|
|
110
|
+
if (serviceRes.success) {
|
|
111
|
+
s.stop(pc.green(`Serviço registrado (${serviceRes.type}): ${serviceRes.name}`));
|
|
112
|
+
} else {
|
|
113
|
+
s.stop(pc.yellow(`Não foi possível registrar serviço automático: ${serviceRes.error}`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Garante que o daemon suba em segundo plano se ainda não estiver rodando
|
|
118
|
+
const isUp = await isServerHealthy(3010);
|
|
119
|
+
if (!isUp) {
|
|
120
|
+
startDaemonBackground(process.argv[1]);
|
|
121
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
p.outro(pc.green('Configuração concluída! Abrindo interface interativa...'));
|
|
125
|
+
await runInteractiveTui();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
s.stop(pc.red('Erro durante o pareamento'));
|
|
128
|
+
p.log.error(err.message || 'Falha ao registrar nó');
|
|
129
|
+
if (err.cause) {
|
|
130
|
+
p.log.error(pc.dim(`Detalhes técnicos: ${err.cause?.message || err.cause?.code || err.cause}`));
|
|
131
|
+
}
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Comando padrão: se nenhum subcomando for passado, abre o TUI
|
|
137
|
+
program
|
|
138
|
+
.action(async () => {
|
|
139
|
+
if (!hasConfig()) {
|
|
140
|
+
p.intro(pc.bold(pc.yellow('Project Foundry - Primeiro Uso')));
|
|
141
|
+
p.note(
|
|
142
|
+
'Este servidor local ainda não foi vinculado a uma conta.\n' +
|
|
143
|
+
'Gere um token de pareamento no painel web (Configurações -> Servidores Locais) e execute:\n\n' +
|
|
144
|
+
pc.cyan(' npx project-foundry setup --token <seu-token>'),
|
|
145
|
+
'Como Vincular'
|
|
146
|
+
);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await runInteractiveTui();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-foundry",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Project Foundry CLI & Local Node Daemon Manager",
|
|
5
|
+
"bin": {
|
|
6
|
+
"project-foundry": "bin/foundry.js",
|
|
7
|
+
"foundry": "bin/foundry.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node ./bin/foundry.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@clack/prompts": "^0.9.1",
|
|
20
|
+
"commander": "^12.1.0",
|
|
21
|
+
"open": "^10.1.0",
|
|
22
|
+
"picocolors": "^1.1.1",
|
|
23
|
+
"ws": "^8.22.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
async function postJson(targetUrl, payload) {
|
|
6
|
+
const parsed = new URL(targetUrl);
|
|
7
|
+
const isHttps = parsed.protocol === 'https:';
|
|
8
|
+
const transport = isHttps ? https : http;
|
|
9
|
+
const postData = JSON.stringify(payload);
|
|
10
|
+
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const req = transport.request(parsed, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
family: 4,
|
|
15
|
+
rejectUnauthorized: false,
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
19
|
+
'User-Agent': 'Project-Foundry-CLI/1.0',
|
|
20
|
+
},
|
|
21
|
+
}, (res) => {
|
|
22
|
+
let body = '';
|
|
23
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
24
|
+
res.on('end', () => {
|
|
25
|
+
try {
|
|
26
|
+
const parsedBody = body ? JSON.parse(body) : {};
|
|
27
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
28
|
+
resolve(parsedBody.data);
|
|
29
|
+
} else {
|
|
30
|
+
const err = new Error(parsedBody?.error?.message || `HTTP ${res.statusCode}: ${body.slice(0, 100)}`);
|
|
31
|
+
err.code = parsedBody?.error?.code;
|
|
32
|
+
reject(err);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
reject(new Error(`Resposta inválida do servidor: HTTP ${res.statusCode}`));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
req.on('error', (err) => {
|
|
41
|
+
reject(new Error(`Falha de conexão com ${parsed.host} (${err.code || err.message})`));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
req.setTimeout(15000, () => {
|
|
45
|
+
req.destroy();
|
|
46
|
+
reject(new Error(`Tempo limite esgotado ao contatar ${parsed.host}`));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
req.write(postData);
|
|
50
|
+
req.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function registerNodeWithCloud({ serverUrl, token, name }) {
|
|
55
|
+
const url = `${serverUrl.replace(/\/$/, '')}/api/v1/nodes/register`;
|
|
56
|
+
|
|
57
|
+
const payload = {
|
|
58
|
+
token,
|
|
59
|
+
hostname: os.hostname(),
|
|
60
|
+
platform: process.platform,
|
|
61
|
+
arch: process.arch,
|
|
62
|
+
name: name || `Servidor ${os.hostname()}`,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return await postJson(url, payload);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchServerTelemetry(port = 3010) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/v1/ready`);
|
|
71
|
+
if (!res.ok) return null;
|
|
72
|
+
return await res.json();
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
export function getConfigDir() {
|
|
6
|
+
if (process.platform === 'win32') {
|
|
7
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Foundry');
|
|
8
|
+
}
|
|
9
|
+
return path.join(os.homedir(), '.foundry');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getConfigPath() {
|
|
13
|
+
return path.join(getConfigDir(), 'config.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getDefaultStorageDir() {
|
|
17
|
+
return path.join(getConfigDir(), 'storage');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function hasConfig() {
|
|
21
|
+
return fs.existsSync(getConfigPath());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readConfig() {
|
|
25
|
+
const filePath = getConfigPath();
|
|
26
|
+
if (!fs.existsSync(filePath)) return null;
|
|
27
|
+
try {
|
|
28
|
+
const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
29
|
+
if (!config.storagePath) {
|
|
30
|
+
config.storagePath = getDefaultStorageDir();
|
|
31
|
+
}
|
|
32
|
+
return config;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeConfig(data) {
|
|
39
|
+
const dir = getConfigDir();
|
|
40
|
+
if (!fs.existsSync(dir)) {
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Se não definido, salva o caminho padrão
|
|
45
|
+
if (!data.storagePath) {
|
|
46
|
+
data.storagePath = getDefaultStorageDir();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Garante que o diretório de storage existe
|
|
50
|
+
if (!fs.existsSync(data.storagePath)) {
|
|
51
|
+
try {
|
|
52
|
+
fs.mkdirSync(data.storagePath, { recursive: true });
|
|
53
|
+
} catch {
|
|
54
|
+
// Ignora erro de permissão imediato, será reportado no setup
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const filePath = getConfigPath();
|
|
59
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function updateStoragePath(newPath) {
|
|
63
|
+
const config = readConfig() || {};
|
|
64
|
+
config.storagePath = path.resolve(newPath);
|
|
65
|
+
if (!fs.existsSync(config.storagePath)) {
|
|
66
|
+
fs.mkdirSync(config.storagePath, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
writeConfig(config);
|
|
69
|
+
return config.storagePath;
|
|
70
|
+
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readConfig } from './config.js';
|
|
5
|
+
import { edgeTunnelClient } from './tunnel-client.js';
|
|
6
|
+
|
|
7
|
+
export function startDaemon() {
|
|
8
|
+
const config = readConfig();
|
|
9
|
+
if (!config) {
|
|
10
|
+
console.error('Configuração não encontrada. Execute "foundry setup" primeiro.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const port = config.localPort || 3010;
|
|
15
|
+
|
|
16
|
+
// 1. Inicia cliente do túnel WebSocket com a Nuvem
|
|
17
|
+
edgeTunnelClient.start();
|
|
18
|
+
console.log(`[Foundry Daemon] Túnel WebSocket conectado a ${config.serverUrl}`);
|
|
19
|
+
|
|
20
|
+
// 2. Servidor HTTP local (Porta 3010) para Slicer e diagnósticos
|
|
21
|
+
const server = http.createServer(async (req, res) => {
|
|
22
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
23
|
+
|
|
24
|
+
// CORS básico
|
|
25
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
26
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
|
|
27
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key, Authorization');
|
|
28
|
+
|
|
29
|
+
if (req.method === 'OPTIONS') {
|
|
30
|
+
res.writeHead(204);
|
|
31
|
+
res.end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Healthcheck local
|
|
36
|
+
if (url.pathname === '/api/v1/ready' || url.pathname === '/api/v1/health') {
|
|
37
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
38
|
+
res.end(JSON.stringify({
|
|
39
|
+
success: true,
|
|
40
|
+
data: {
|
|
41
|
+
ready: true,
|
|
42
|
+
status: 'healthy',
|
|
43
|
+
nodeId: config.nodeId,
|
|
44
|
+
organizationId: config.organizationId,
|
|
45
|
+
storagePath: config.storagePath,
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Slicer version handshake
|
|
53
|
+
if (url.pathname.match(/^\/api\/v1\/slicer-host\/[^/]+\/api\/version$/)) {
|
|
54
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
55
|
+
res.end(JSON.stringify({
|
|
56
|
+
api: '0.1',
|
|
57
|
+
server: '1.10.0',
|
|
58
|
+
text: 'OctoPrint 1.10.0 (Project Foundry Local Bridge)',
|
|
59
|
+
}));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Upload do OrcaSlicer recebido na LAN: salva direto no HD local e repassa à nuvem
|
|
64
|
+
if (url.pathname.match(/^\/api\/v1\/slicer-host\/[^/]+\/api\/files\/local$/) && req.method === 'POST') {
|
|
65
|
+
const storageDir = config.storagePath || path.join(process.cwd(), 'storage');
|
|
66
|
+
if (!fs.existsSync(storageDir)) {
|
|
67
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tempFileName = `upload_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
71
|
+
const tempFilePath = path.join(storageDir, tempFileName);
|
|
72
|
+
const fileStream = fs.createWriteStream(tempFilePath);
|
|
73
|
+
|
|
74
|
+
req.pipe(fileStream);
|
|
75
|
+
|
|
76
|
+
fileStream.on('finish', async () => {
|
|
77
|
+
try {
|
|
78
|
+
// Encaminha requisição completa com cabeçalhos para a nuvem
|
|
79
|
+
const cloudUrl = `${config.serverUrl.replace(/\/$/, '')}${url.pathname}`;
|
|
80
|
+
const cloudReq = http.request(cloudUrl, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
...req.headers,
|
|
84
|
+
host: new URL(config.serverUrl).host,
|
|
85
|
+
},
|
|
86
|
+
}, (cloudRes) => {
|
|
87
|
+
res.writeHead(cloudRes.statusCode, cloudRes.headers);
|
|
88
|
+
cloudRes.pipe(res);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
cloudReq.on('error', (err) => {
|
|
92
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
93
|
+
res.end(JSON.stringify({
|
|
94
|
+
success: false,
|
|
95
|
+
error: { message: `Erro ao notificar nuvem: ${err.message}` },
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
fs.createReadStream(tempFilePath).pipe(cloudReq);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ success: false, error: { message: err.message } }));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
fileStream.on('error', (err) => {
|
|
107
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ success: false, error: { message: err.message } }));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Proxy reverso padrão para outras rotas em direção à Nuvem
|
|
115
|
+
try {
|
|
116
|
+
const targetUrl = new URL(req.url, config.serverUrl);
|
|
117
|
+
const isHttps = targetUrl.protocol === 'https:';
|
|
118
|
+
const transport = isHttps ? await import('node:https') : await import('node:http');
|
|
119
|
+
|
|
120
|
+
const proxyReq = transport.request(targetUrl, {
|
|
121
|
+
method: req.method,
|
|
122
|
+
headers: {
|
|
123
|
+
...req.headers,
|
|
124
|
+
host: targetUrl.host,
|
|
125
|
+
},
|
|
126
|
+
}, (proxyRes) => {
|
|
127
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
128
|
+
proxyRes.pipe(res);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
proxyReq.on('error', (err) => {
|
|
132
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
133
|
+
res.end(JSON.stringify({ success: false, error: { message: `Erro no proxy da nuvem: ${err.message}` } }));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
req.pipe(proxyReq);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
139
|
+
res.end(JSON.stringify({ success: false, error: { message: err.message } }));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
server.listen(port, '0.0.0.0', () => {
|
|
144
|
+
console.log(`[Foundry Daemon] Servidor local escutando em http://0.0.0.0:${port}`);
|
|
145
|
+
console.log(`[Foundry Daemon] Diretório de arquivos: ${config.storagePath}`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const handleExit = () => {
|
|
149
|
+
console.log('[Foundry Daemon] Parando serviços...');
|
|
150
|
+
edgeTunnelClient.stop();
|
|
151
|
+
server.close(() => process.exit(0));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
process.on('SIGINT', handleExit);
|
|
155
|
+
process.on('SIGTERM', handleExit);
|
|
156
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execSync, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
export async function isServerHealthy(port = 3010) {
|
|
7
|
+
try {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
10
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/v1/ready`, { signal: controller.signal });
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
return res.status === 200;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function installAutostartService(serverEntryPath) {
|
|
19
|
+
const isWin = process.platform === 'win32';
|
|
20
|
+
|
|
21
|
+
if (isWin) {
|
|
22
|
+
try {
|
|
23
|
+
// Cria tarefa no Agendador do Windows com início automático em modo daemon
|
|
24
|
+
const cmd = `schtasks /create /tn "ProjectFoundry" /tr "node \\"${serverEntryPath}\\" daemon" /sc onstart /f`;
|
|
25
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
26
|
+
return { success: true, type: 'windows-task', name: 'ProjectFoundry' };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { success: false, error: err.message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Linux (systemd)
|
|
33
|
+
try {
|
|
34
|
+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
35
|
+
const serviceContent = `[Unit]
|
|
36
|
+
Description=Project Foundry Local Server
|
|
37
|
+
After=network.target
|
|
38
|
+
|
|
39
|
+
[Service]
|
|
40
|
+
Type=simple
|
|
41
|
+
ExecStart=/usr/bin/node ${serverEntryPath} daemon
|
|
42
|
+
Restart=always
|
|
43
|
+
RestartSec=5
|
|
44
|
+
Environment=NODE_ENV=production
|
|
45
|
+
|
|
46
|
+
[Install]
|
|
47
|
+
WantedBy=default.target
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
if (isRoot) {
|
|
51
|
+
fs.writeFileSync('/etc/systemd/system/foundry.service', serviceContent, 'utf8');
|
|
52
|
+
execSync('systemctl daemon-reload && systemctl enable foundry.service', { stdio: 'pipe' });
|
|
53
|
+
return { success: true, type: 'systemd-system', name: 'foundry.service' };
|
|
54
|
+
} else {
|
|
55
|
+
const userSystemdDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
56
|
+
fs.mkdirSync(userSystemdDir, { recursive: true });
|
|
57
|
+
fs.writeFileSync(path.join(userSystemdDir, 'foundry.service'), serviceContent, 'utf8');
|
|
58
|
+
execSync('systemctl --user daemon-reload && systemctl --user enable foundry.service', { stdio: 'pipe' });
|
|
59
|
+
return { success: true, type: 'systemd-user', name: 'foundry.service' };
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { success: false, error: err.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function startDaemonBackground(serverEntryPath) {
|
|
67
|
+
// Inicia desacoplado do terminal para não travar
|
|
68
|
+
const child = spawn(process.execPath, [serverEntryPath, 'daemon'], {
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
});
|
|
72
|
+
child.unref();
|
|
73
|
+
return child.pid;
|
|
74
|
+
}
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { readConfig, updateStoragePath } from './config.js';
|
|
5
|
+
import { isServerHealthy } from './service-manager.js';
|
|
6
|
+
|
|
7
|
+
export async function runInteractiveTui() {
|
|
8
|
+
const config = readConfig();
|
|
9
|
+
const version = 'v1.0.0';
|
|
10
|
+
const localPort = config?.localPort || 3010;
|
|
11
|
+
const localUrl = `http://localhost:${localPort}`;
|
|
12
|
+
|
|
13
|
+
console.clear();
|
|
14
|
+
|
|
15
|
+
p.intro(
|
|
16
|
+
`${pc.bold(pc.cyan('======================================================'))}\n` +
|
|
17
|
+
` ${pc.bold(pc.yellow(`Project Foundry Interface (${version})`))}\n` +
|
|
18
|
+
` 🚀 Server: ${pc.green(localUrl)}\n` +
|
|
19
|
+
` 💾 Storage: ${pc.cyan(config?.storagePath || 'Padrão')}\n` +
|
|
20
|
+
`${pc.bold(pc.cyan('======================================================'))}`
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const healthy = await isServerHealthy(localPort);
|
|
24
|
+
|
|
25
|
+
if (healthy) {
|
|
26
|
+
p.note(
|
|
27
|
+
`${pc.green('●')} Servidor Local Online\n` +
|
|
28
|
+
`Empresa: ${config?.organizationId ? pc.bold(config.organizationId) : 'Autônoma'}\n` +
|
|
29
|
+
`Nó: ${pc.bold(config?.nodeName || 'Servidor Local')}\n` +
|
|
30
|
+
`Armazenamento de Arquivos: ${pc.bold(config?.storagePath || 'Padrão')}`,
|
|
31
|
+
'Status da Farm'
|
|
32
|
+
);
|
|
33
|
+
} else {
|
|
34
|
+
p.note(
|
|
35
|
+
`${pc.yellow('○')} O servidor local não respondeu em ${localUrl}.\n` +
|
|
36
|
+
`Ele pode estar iniciando ou parado.`,
|
|
37
|
+
'Atenção'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
while (true) {
|
|
42
|
+
const choice = await p.select({
|
|
43
|
+
message: 'Escolha uma opção:',
|
|
44
|
+
options: [
|
|
45
|
+
{ value: 'web', label: 'Web UI (Open in Browser)', hint: 'Abre o painel no navegador' },
|
|
46
|
+
{ value: 'status', label: 'Terminal UI (Status & Health)', hint: 'Verifica conexões locais' },
|
|
47
|
+
{ value: 'storage', label: 'Storage (HD Externo)', hint: 'Visualizar ou alterar pasta de arquivos' },
|
|
48
|
+
{ value: 'update', label: `Update (current: ${version})`, hint: 'Verifica atualizações' },
|
|
49
|
+
{ value: 'hide', label: 'Hide to Tray (Background)', hint: 'Mantém servidor rodando em segundo plano' },
|
|
50
|
+
{ value: 'exit', label: 'Exit', hint: 'Fechar console' },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (p.isCancel(choice) || choice === 'hide') {
|
|
55
|
+
p.outro(pc.green('Servidor mantido rodando em segundo plano. Digite "foundry" para reabrir este menu.'));
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (choice === 'web') {
|
|
60
|
+
p.log.info(`Abrindo ${localUrl} no navegador...`);
|
|
61
|
+
await open(localUrl).catch(() => {
|
|
62
|
+
p.log.warn(`Não foi possível abrir o navegador automaticamente. Acesse: ${localUrl}`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (choice === 'status') {
|
|
67
|
+
const isUp = await isServerHealthy(localPort);
|
|
68
|
+
if (isUp) {
|
|
69
|
+
p.log.success(`Servidor respondendo perfeitamente em ${localUrl}`);
|
|
70
|
+
} else {
|
|
71
|
+
p.log.error(`Servidor inacessível na porta ${localPort}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (choice === 'storage') {
|
|
76
|
+
const currentConf = readConfig();
|
|
77
|
+
p.log.info(`Pasta atual de arquivos: ${pc.bold(currentConf?.storagePath || 'Padrão')}`);
|
|
78
|
+
const wantChange = await p.confirm({
|
|
79
|
+
message: 'Deseja alterar a pasta de armazenamento (ex: novo HD)?',
|
|
80
|
+
initialValue: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (wantChange && !p.isCancel(wantChange)) {
|
|
84
|
+
const newPath = await p.text({
|
|
85
|
+
message: 'Digite o novo caminho completo da pasta do HD:',
|
|
86
|
+
placeholder: process.platform === 'win32' ? 'D:\\Storage' : '/mnt/hd1tb/storage',
|
|
87
|
+
validate: (val) => {
|
|
88
|
+
if (!val || !val.trim()) return 'Caminho é obrigatório';
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (!p.isCancel(newPath) && newPath.trim()) {
|
|
92
|
+
try {
|
|
93
|
+
const updated = updateStoragePath(newPath.trim());
|
|
94
|
+
p.log.success(`Caminho de armazenamento atualizado para: ${pc.bold(updated)}`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
p.log.error(`Erro ao atualizar pasta: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (choice === 'update') {
|
|
103
|
+
p.log.info('Você já está na versão mais recente instalada.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (choice === 'exit') {
|
|
107
|
+
const shouldStop = await p.confirm({
|
|
108
|
+
message: 'Deseja apenas fechar o console ou parar o serviço?',
|
|
109
|
+
active: 'Apenas fechar console (manter servidor ligado)',
|
|
110
|
+
inactive: 'Fechar console',
|
|
111
|
+
initialValue: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
p.outro(pc.cyan('Até logo! O servidor continua em segundo plano.'));
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
|
|
4
|
+
export class EdgeTunnelClient {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.ws = null;
|
|
7
|
+
this.pingTimer = null;
|
|
8
|
+
this.reconnectTimer = null;
|
|
9
|
+
this.isStopping = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
start() {
|
|
13
|
+
const config = readConfig();
|
|
14
|
+
if (!config || !config.serverUrl || !config.apiKey) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
this.isStopping = false;
|
|
19
|
+
this.connect();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
stop() {
|
|
23
|
+
this.isStopping = true;
|
|
24
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
25
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
26
|
+
if (this.ws) {
|
|
27
|
+
try {
|
|
28
|
+
this.ws.close();
|
|
29
|
+
} catch {}
|
|
30
|
+
this.ws = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
connect() {
|
|
35
|
+
if (this.isStopping) return;
|
|
36
|
+
|
|
37
|
+
const config = readConfig();
|
|
38
|
+
if (!config) return;
|
|
39
|
+
|
|
40
|
+
const base = config.serverUrl.replace(/\/$/, '');
|
|
41
|
+
const wsProto = base.startsWith('https') ? 'wss' : 'ws';
|
|
42
|
+
const hostPart = base.replace(/^https?:\/\//, '');
|
|
43
|
+
const url = `${wsProto}://${hostPart}/api/v1/nodes/tunnel?token=${encodeURIComponent(config.apiKey)}`;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
this.ws = new WebSocket(url, {
|
|
47
|
+
rejectUnauthorized: false,
|
|
48
|
+
family: 4,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.ws.on('open', () => {
|
|
52
|
+
// Envia PING a cada 25 segundos
|
|
53
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
54
|
+
this.pingTimer = setInterval(() => {
|
|
55
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
56
|
+
this.ws.send(JSON.stringify({ type: 'PING' }));
|
|
57
|
+
}
|
|
58
|
+
}, 25000);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.ws.on('message', async (data) => {
|
|
62
|
+
try {
|
|
63
|
+
const msg = JSON.parse(data.toString());
|
|
64
|
+
if (msg.type === 'COMMAND' && msg.correlationId) {
|
|
65
|
+
await this.handleCloudCommand(msg);
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this.ws.on('close', () => {
|
|
71
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
72
|
+
this.scheduleReconnect();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.ws.on('error', () => {
|
|
76
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
77
|
+
this.scheduleReconnect();
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
this.scheduleReconnect();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
scheduleReconnect() {
|
|
85
|
+
if (this.isStopping) return;
|
|
86
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
87
|
+
this.reconnectTimer = setTimeout(() => {
|
|
88
|
+
this.connect();
|
|
89
|
+
}, 5000);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async handleCloudCommand(msg) {
|
|
93
|
+
const config = readConfig();
|
|
94
|
+
const localPort = config?.localPort || 3010;
|
|
95
|
+
const localBase = `http://127.0.0.1:${localPort}/api/v1`;
|
|
96
|
+
const headers = {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'X-Api-Key': config.apiKey,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
let resultData = null;
|
|
103
|
+
|
|
104
|
+
if (msg.action === 'camera/snapshot') {
|
|
105
|
+
const snapRes = await fetch(`${localBase}/printers/${msg.printerId}/camera/snapshot`, {
|
|
106
|
+
headers: { 'X-Api-Key': config.apiKey },
|
|
107
|
+
});
|
|
108
|
+
if (!snapRes.ok) throw new Error(`Snapshot falhou: HTTP ${snapRes.status}`);
|
|
109
|
+
const arrayBuf = await snapRes.arrayBuffer();
|
|
110
|
+
resultData = { base64: Buffer.from(arrayBuf).toString('base64') };
|
|
111
|
+
} else if (msg.action === 'control') {
|
|
112
|
+
const cRes = await fetch(`${localBase}/printers/${msg.printerId}/control`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers,
|
|
115
|
+
body: JSON.stringify(msg.payload || {}),
|
|
116
|
+
});
|
|
117
|
+
const cJson = await cRes.json();
|
|
118
|
+
if (!cRes.ok) throw new Error(cJson?.error?.message || 'Falha no comando de controle');
|
|
119
|
+
resultData = cJson.data;
|
|
120
|
+
} else if (msg.action === 'connect') {
|
|
121
|
+
const connRes = await fetch(`${localBase}/printers/${msg.printerId}/connect`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers,
|
|
124
|
+
body: JSON.stringify({}),
|
|
125
|
+
});
|
|
126
|
+
const connJson = await connRes.json();
|
|
127
|
+
if (!connRes.ok) throw new Error(connJson?.error?.message || 'Falha ao conectar à impressora local');
|
|
128
|
+
resultData = connJson.data;
|
|
129
|
+
} else if (msg.action.startsWith('job/')) {
|
|
130
|
+
const jobAction = msg.action.replace('job/', '');
|
|
131
|
+
const jRes = await fetch(`${localBase}/jobs/${msg.payload?.jobId}/${jobAction}`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers,
|
|
134
|
+
body: JSON.stringify({}),
|
|
135
|
+
});
|
|
136
|
+
const jJson = await jRes.json();
|
|
137
|
+
if (!jRes.ok) throw new Error(jJson?.error?.message || `Falha no comando do trabalho: ${jobAction}`);
|
|
138
|
+
resultData = jJson.data;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
142
|
+
this.ws.send(JSON.stringify({
|
|
143
|
+
type: 'COMMAND_RESPONSE',
|
|
144
|
+
correlationId: msg.correlationId,
|
|
145
|
+
data: resultData,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
150
|
+
this.ws.send(JSON.stringify({
|
|
151
|
+
type: 'COMMAND_RESPONSE',
|
|
152
|
+
correlationId: msg.correlationId,
|
|
153
|
+
error: err.message,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const edgeTunnelClient = new EdgeTunnelClient();
|