localhostly 0.0.0-stage → 1.0.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.
@@ -0,0 +1,335 @@
1
+ // O agente: uma conexão de saída ao servidor e cada requisição reproduzida na porta local.
2
+ import { EventEmitter } from 'node:events';
3
+ import http from 'node:http';
4
+ import { performance } from 'node:perf_hooks';
5
+ import WebSocket from 'ws';
6
+ import { Canal } from '../lib/canal.js';
7
+ import { filtrar, paraObjeto, sem, valor } from '../lib/cabecalhos.js';
8
+ import { entregar, Remetente, segurarWs } from '../lib/fluxo.js';
9
+ import { codigoEnviavel, TIPO } from '../lib/protocolo.js';
10
+
11
+ export const SERVIDOR_PADRAO = 'wss://localhostly.iblabs.io/agent';
12
+ export const RECUO_MIN = 1_000;
13
+ export const RECUO_MAX = 60_000;
14
+ export const JITTER = 0.2;
15
+
16
+ // A espera antes da tentativa n (0 = a primeira depois da queda): min(max, min·2^n) ± 20 %, nunca acima de max
17
+ // (com a rede de volta, o túnel volta em até um minuto).
18
+ export function recuo(n, { min = RECUO_MIN, max = RECUO_MAX, jitter = JITTER, aleatorio = Math.random } = {}) {
19
+ const base = Math.min(max, min * 2 ** Math.min(n, 30));
20
+ return Math.round(Math.min(max, base * (1 + (aleatorio() * 2 - 1) * jitter)));
21
+ }
22
+
23
+ function paginaErro(alvo, erro) {
24
+ return (
25
+ '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>502 · localhostly</title></head>' +
26
+ '<body style="font:16px/1.5 system-ui,sans-serif;max-width:36rem;margin:15vh auto;padding:0 1rem">' +
27
+ `<h1>502 · Local app unreachable</h1><p>localhostly could not reach <code>http://${alvo}</code>` +
28
+ ` (${String(erro.code ?? erro.message).replace(/[<>&]/g, '')}). Is it running?</p></body></html>\n`
29
+ );
30
+ }
31
+
32
+ // alvo: { host, porta } do app local; hostCabecalho: o Host que o app recebe (null = o original).
33
+ export class Tunel extends EventEmitter {
34
+ constructor({
35
+ servidor = SERVIDOR_PADRAO,
36
+ token = null,
37
+ alvo,
38
+ hostCabecalho = null,
39
+ senha = null,
40
+ reconectar = true,
41
+ recuoMin = RECUO_MIN,
42
+ recuoMax = RECUO_MAX,
43
+ silencioMs = 90_000,
44
+ handshakeMs = 15_000,
45
+ // a sessão de antes ({ nome, tokenSessao }): a primeira conexão já pede o mesmo nome
46
+ sessao = null,
47
+ // recusado pelo servidor (sem token, limite de túneis…): tenta de novo no recuo em vez de parar (o serviço)
48
+ insistir = false,
49
+ }) {
50
+ super();
51
+ this.servidor = servidor;
52
+ this.token = token;
53
+ this.alvo = alvo;
54
+ this.hostCabecalho = hostCabecalho;
55
+ this.senha = senha;
56
+ this.reconectar = reconectar;
57
+ this.silencioMs = silencioMs;
58
+ this.handshakeMs = handshakeMs;
59
+ this.recuoMin = recuoMin;
60
+ this.recuoMax = recuoMax;
61
+ this.insistir = insistir;
62
+ this.tentativas = 0; // falhas seguidas desde o último "ready"
63
+ // { nome, tokenSessao } do último "ready": a reconexão retoma o mesmo nome
64
+ this.sessao = sessao?.nome && sessao?.tokenSessao ? { nome: sessao.nome, tokenSessao: sessao.tokenSessao } : null;
65
+ this.fechado = false;
66
+ this.nome = null;
67
+ this.url = null;
68
+ this.ws = null;
69
+ this.agenteHttp = new http.Agent({ keepAlive: true });
70
+ this.timerReconexao = null;
71
+ }
72
+
73
+ get enderecoLocal() {
74
+ const h = this.alvo.host.includes(':') ? `[${this.alvo.host}]` : this.alvo.host;
75
+ return `${h}:${this.alvo.porta}`;
76
+ }
77
+
78
+ abrir() {
79
+ this.#conectar();
80
+ return this;
81
+ }
82
+
83
+ // Espera o primeiro "ready" (ou o erro do servidor).
84
+ pronto() {
85
+ if (this.nome) return Promise.resolve({ nome: this.nome, url: this.url });
86
+ return new Promise((resolve, reject) => {
87
+ const ok = (info) => {
88
+ this.off('recusado', falha);
89
+ resolve(info);
90
+ };
91
+ const falha = (erro) => {
92
+ this.off('pronto', ok);
93
+ reject(erro);
94
+ };
95
+ this.once('pronto', ok);
96
+ this.once('recusado', falha);
97
+ });
98
+ }
99
+
100
+ fechar() {
101
+ this.fechado = true;
102
+ clearTimeout(this.timerReconexao);
103
+ this.ws?.terminate();
104
+ this.agenteHttp.destroy();
105
+ }
106
+
107
+ #conectar() {
108
+ // o prazo do aperto de mão: com a rede voltando, um SYN perdido não segura a tentativa por minutos
109
+ const ws = new WebSocket(this.servidor, {
110
+ perMessageDeflate: false,
111
+ maxPayload: 1024 * 1024,
112
+ handshakeTimeout: this.handshakeMs,
113
+ });
114
+ this.ws = ws;
115
+ const canal = new Canal(ws);
116
+ let recusado = false;
117
+ const pedido = this.sessao?.nome ?? null;
118
+ let silencio = null;
119
+ const vigiar = () => {
120
+ clearTimeout(silencio);
121
+ silencio = setTimeout(() => ws.terminate(), this.silencioMs);
122
+ };
123
+
124
+ ws.on('open', () => {
125
+ vigiar();
126
+ canal.enviarControle({
127
+ t: 'hello',
128
+ token: this.token,
129
+ nome: this.sessao?.nome ?? null,
130
+ tokenSessao: this.sessao?.tokenSessao ?? null,
131
+ host: this.hostCabecalho,
132
+ password: this.senha,
133
+ });
134
+ });
135
+ ws.on('ping', vigiar);
136
+ ws.on('message', vigiar);
137
+ ws.on('error', (erro) => this.emit('aviso', erro));
138
+ canal.on('invalido', () => ws.terminate());
139
+ canal.on('controle', (msg) => {
140
+ if (msg.t === 'ready') {
141
+ this.tentativas = 0;
142
+ this.nome = msg.nome;
143
+ this.url = msg.url;
144
+ this.sessao = msg.tokenSessao ? { nome: msg.nome, tokenSessao: msg.tokenSessao } : null;
145
+ // pediu um nome e veio outro (a reserva acabou, o servidor reiniciou): segue com o novo e avisa
146
+ if (pedido && pedido !== msg.nome) this.emit('nome_trocado', { de: pedido, para: msg.nome });
147
+ this.emit('pronto', {
148
+ nome: msg.nome,
149
+ url: msg.url,
150
+ retomado: Boolean(msg.retomado),
151
+ tokenSessao: msg.tokenSessao ?? null,
152
+ });
153
+ } else if (msg.t === 'erro') {
154
+ recusado = true;
155
+ const erro = new Error(msg.mensagem || msg.codigo);
156
+ erro.codigo = msg.codigo;
157
+ this.emit('recusado', erro);
158
+ }
159
+ });
160
+ canal.aoAbrir = (quadro) => {
161
+ if (quadro.tipo === TIPO.REQ_INICIO) this.#requisicao(canal, quadro);
162
+ else this.#websocket(canal, quadro);
163
+ };
164
+ ws.on('close', () => {
165
+ clearTimeout(silencio);
166
+ const tinhaNome = this.nome;
167
+ this.nome = null;
168
+ this.url = null;
169
+ if (this.fechado) return;
170
+ if (tinhaNome) this.emit('caiu', { nome: tinhaNome });
171
+ if ((recusado && !this.insistir) || !this.reconectar) {
172
+ this.emit('fim');
173
+ return;
174
+ }
175
+ const espera = recuo(this.tentativas++, { min: this.recuoMin, max: this.recuoMax });
176
+ this.emit('reconectando', { emMs: espera, tentativa: this.tentativas });
177
+ this.timerReconexao = setTimeout(() => this.#conectar(), espera);
178
+ });
179
+ }
180
+
181
+ #requisicao(canal, { id, payload }) {
182
+ const inicio = performance.now();
183
+ const { metodo, caminho, cabecalhos = [] } = payload;
184
+ const remetente = new Remetente(canal, id, TIPO.RES_CORPO, TIPO.RES_FIM);
185
+ let respondeu = false;
186
+ let acabou = false;
187
+ let status = 0;
188
+
189
+ const terminar = () => {
190
+ if (acabou) return;
191
+ acabou = true;
192
+ remetente.encerrar();
193
+ canal.soltar(id);
194
+ this.emit('requisicao', { metodo, caminho, status, ms: Math.round(performance.now() - inicio) });
195
+ };
196
+
197
+ const local = http.request({
198
+ host: this.alvo.host,
199
+ port: this.alvo.porta,
200
+ method: metodo,
201
+ path: caminho,
202
+ headers: cabecalhos,
203
+ agent: this.agenteHttp,
204
+ });
205
+
206
+ canal.registrar(id, {
207
+ quadro: ({ tipo, payload: p }) => {
208
+ if (tipo === TIPO.REQ_CORPO) entregar(local, p, canal, id);
209
+ else if (tipo === TIPO.REQ_FIM) local.end();
210
+ else if (tipo === TIPO.JANELA) remetente.credito(p);
211
+ else if (tipo === TIPO.ABORTAR) {
212
+ status = status || 499;
213
+ local.destroy();
214
+ terminar();
215
+ }
216
+ },
217
+ fechou: () => {
218
+ local.destroy();
219
+ terminar();
220
+ },
221
+ });
222
+
223
+ local.on('response', (res) => {
224
+ respondeu = true;
225
+ status = res.statusCode;
226
+ canal.enviar(TIPO.RES_INICIO, id, {
227
+ status: res.statusCode,
228
+ mensagem: res.statusMessage,
229
+ cabecalhos: filtrar(res.rawHeaders),
230
+ });
231
+ remetente.ligar(res);
232
+ res.on('end', terminar);
233
+ res.on('error', () => {
234
+ if (!acabou) canal.enviar(TIPO.ABORTAR, id, { motivo: 'local' });
235
+ terminar();
236
+ });
237
+ });
238
+
239
+ local.on('error', (erro) => {
240
+ if (acabou) return;
241
+ if (!respondeu) {
242
+ status = 502;
243
+ const corpo = Buffer.from(paginaErro(this.enderecoLocal, erro));
244
+ canal.enviar(TIPO.RES_INICIO, id, {
245
+ status: 502,
246
+ cabecalhos: ['Content-Type', 'text/html; charset=utf-8', 'Content-Length', String(corpo.length)],
247
+ });
248
+ canal.enviar(TIPO.RES_CORPO, id, corpo);
249
+ canal.enviar(TIPO.RES_FIM, id);
250
+ } else {
251
+ canal.enviar(TIPO.ABORTAR, id, { motivo: 'local' });
252
+ }
253
+ terminar();
254
+ });
255
+ }
256
+
257
+ #websocket(canal, { id, payload }) {
258
+ const inicio = performance.now();
259
+ const { caminho = '/', cabecalhos = [] } = payload;
260
+ const protocolos = (valor(cabecalhos, 'sec-websocket-protocol') ?? '')
261
+ .split(',')
262
+ .map((p) => p.trim())
263
+ .filter(Boolean);
264
+ const headers = paraObjeto(sem(cabecalhos, ['sec-websocket-protocol']));
265
+ let aberto = false;
266
+ let acabou = false;
267
+
268
+ const terminar = (status) => {
269
+ if (acabou) return;
270
+ acabou = true;
271
+ canal.soltar(id);
272
+ if (!aberto) {
273
+ this.emit('requisicao', { metodo: 'WS', caminho, status, ms: Math.round(performance.now() - inicio) });
274
+ }
275
+ };
276
+
277
+ const local = new WebSocket(`ws://${this.enderecoLocal}${caminho}`, protocolos, {
278
+ headers,
279
+ perMessageDeflate: false,
280
+ });
281
+
282
+ canal.registrar(id, {
283
+ quadro: ({ tipo, payload: p }) => {
284
+ if (tipo === TIPO.WS_DADO) {
285
+ if (local.readyState === WebSocket.OPEN) local.send(p.dados, { binary: p.binario });
286
+ } else if (tipo === TIPO.WS_FECHAR) {
287
+ acabou = true;
288
+ canal.soltar(id);
289
+ if (codigoEnviavel(p.codigo)) local.close(p.codigo, String(p.motivo ?? '').slice(0, 120));
290
+ else local.close();
291
+ } else if (tipo === TIPO.ABORTAR) {
292
+ local.terminate();
293
+ terminar(499);
294
+ }
295
+ },
296
+ fechou: () => {
297
+ local.terminate();
298
+ terminar(499);
299
+ },
300
+ });
301
+
302
+ local.on('open', () => {
303
+ aberto = true;
304
+ canal.enviar(TIPO.RES_INICIO, id, { status: 101, protocolo: local.protocol || null });
305
+ this.emit('requisicao', { metodo: 'WS', caminho, status: 101, ms: Math.round(performance.now() - inicio) });
306
+ });
307
+ local.on('unexpected-response', (req, res) => {
308
+ if (!acabou) {
309
+ canal.enviar(TIPO.RES_INICIO, id, { status: res.statusCode, motivo: res.statusMessage });
310
+ terminar(res.statusCode);
311
+ }
312
+ res.resume();
313
+ req.destroy();
314
+ });
315
+ local.on('message', (dados, binario) => {
316
+ canal.enviar(TIPO.WS_DADO, id, { binario, dados: Buffer.isBuffer(dados) ? dados : Buffer.concat(dados) });
317
+ segurarWs(local, canal);
318
+ });
319
+ local.on('error', (erro) => {
320
+ if (acabou) return;
321
+ if (!aberto) canal.enviar(TIPO.RES_INICIO, id, { status: 502, motivo: String(erro.code ?? erro.message) });
322
+ else canal.enviar(TIPO.WS_FECHAR, id, { codigo: 1011, motivo: 'local error' });
323
+ terminar(502);
324
+ });
325
+ local.on('close', (codigo, motivo) => {
326
+ if (acabou) return;
327
+ canal.enviar(TIPO.WS_FECHAR, id, { codigo, motivo: motivo.toString('utf8') });
328
+ terminar(101);
329
+ });
330
+ }
331
+ }
332
+
333
+ export function abrirTunel(opcoes) {
334
+ return new Tunel(opcoes).abrir();
335
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { principal } from '../agente/cli.js';
3
+
4
+ process.exitCode = await principal();
5
+ process.exit();
@@ -0,0 +1,63 @@
1
+ // Cabeçalhos em lista plana [nome, valor, nome, valor, ...], como o rawHeaders do Node:
2
+ // preserva repetidos (Set-Cookie) e a grafia original.
3
+
4
+ const SALTO = new Set([
5
+ 'connection',
6
+ 'keep-alive',
7
+ 'proxy-authenticate',
8
+ 'proxy-authorization',
9
+ 'proxy-connection',
10
+ 'te',
11
+ 'trailer',
12
+ 'transfer-encoding',
13
+ 'upgrade',
14
+ ]);
15
+
16
+ // Tira os de salto (hop-by-hop), os citados no Connection e os extras pedidos.
17
+ export function filtrar(lista, extras = []) {
18
+ const fora = new Set(extras.map((n) => n.toLowerCase()));
19
+ for (let i = 0; i < lista.length; i += 2) {
20
+ if (String(lista[i]).toLowerCase() === 'connection') {
21
+ for (const n of String(lista[i + 1]).split(',')) {
22
+ const nome = n.trim().toLowerCase();
23
+ if (nome) fora.add(nome);
24
+ }
25
+ }
26
+ }
27
+ const saida = [];
28
+ for (let i = 0; i + 1 < lista.length; i += 2) {
29
+ const nome = String(lista[i]);
30
+ const baixo = nome.toLowerCase();
31
+ if (SALTO.has(baixo) || fora.has(baixo)) continue;
32
+ saida.push(nome, String(lista[i + 1]));
33
+ }
34
+ return saida;
35
+ }
36
+
37
+ export function valor(lista, nome) {
38
+ const alvo = nome.toLowerCase();
39
+ const achados = [];
40
+ for (let i = 0; i + 1 < lista.length; i += 2) {
41
+ if (String(lista[i]).toLowerCase() === alvo) achados.push(String(lista[i + 1]));
42
+ }
43
+ return achados.length ? achados.join(', ') : undefined;
44
+ }
45
+
46
+ export function sem(lista, nomes) {
47
+ const fora = new Set(nomes.map((n) => n.toLowerCase()));
48
+ const saida = [];
49
+ for (let i = 0; i + 1 < lista.length; i += 2) {
50
+ if (!fora.has(String(lista[i]).toLowerCase())) saida.push(lista[i], lista[i + 1]);
51
+ }
52
+ return saida;
53
+ }
54
+
55
+ // Lista plana -> objeto (repetidos juntados por vírgula), para APIs que só aceitam objeto.
56
+ export function paraObjeto(lista) {
57
+ const obj = {};
58
+ for (let i = 0; i + 1 < lista.length; i += 2) {
59
+ const nome = String(lista[i]).toLowerCase();
60
+ obj[nome] = nome in obj ? `${obj[nome]}, ${lista[i + 1]}` : String(lista[i + 1]);
61
+ }
62
+ return obj;
63
+ }
package/lib/canal.js ADDED
@@ -0,0 +1,87 @@
1
+ // O canal: um WebSocket entre servidor e agente com vários fluxos (ids) em voo.
2
+ import { EventEmitter } from 'node:events';
3
+ import WebSocket from 'ws';
4
+ import { codificar, decodificar, TIPO } from './protocolo.js';
5
+
6
+ const ABRE_FLUXO = new Set([TIPO.REQ_INICIO, TIPO.WS_ABRIR]);
7
+
8
+ export class Canal extends EventEmitter {
9
+ constructor(ws) {
10
+ super();
11
+ this.ws = ws;
12
+ this.fluxos = new Map();
13
+ this.proximoId = 1;
14
+ this.aoAbrir = null; // (quadro) => void, no lado que recebe pedidos (o agente)
15
+ ws.on('message', (dados, binario) => this.#receber(dados, binario));
16
+ ws.on('close', () => this.#fechou());
17
+ }
18
+
19
+ get aberto() {
20
+ return this.ws.readyState === WebSocket.OPEN;
21
+ }
22
+
23
+ get bufferedAmount() {
24
+ return this.ws.bufferedAmount;
25
+ }
26
+
27
+ novoId() {
28
+ const id = this.proximoId;
29
+ this.proximoId = this.proximoId >= 0xffffffff ? 1 : this.proximoId + 1;
30
+ return id;
31
+ }
32
+
33
+ registrar(id, fluxo) {
34
+ this.fluxos.set(id, fluxo);
35
+ }
36
+
37
+ soltar(id) {
38
+ this.fluxos.delete(id);
39
+ }
40
+
41
+ enviar(tipo, id, payload) {
42
+ if (!this.aberto) return false;
43
+ this.ws.send(codificar(tipo, id, payload), { binary: true });
44
+ return true;
45
+ }
46
+
47
+ enviarControle(msg) {
48
+ if (!this.aberto) return false;
49
+ this.ws.send(JSON.stringify(msg));
50
+ return true;
51
+ }
52
+
53
+ #receber(dados, binario) {
54
+ if (!binario) {
55
+ let msg;
56
+ try {
57
+ msg = JSON.parse(dados.toString('utf8'));
58
+ } catch {
59
+ this.emit('invalido', new Error('controle que não é JSON'));
60
+ return;
61
+ }
62
+ this.emit('controle', msg);
63
+ return;
64
+ }
65
+ let quadro;
66
+ try {
67
+ quadro = decodificar(dados);
68
+ } catch (erro) {
69
+ this.emit('invalido', erro);
70
+ return;
71
+ }
72
+ const fluxo = this.fluxos.get(quadro.id);
73
+ if (fluxo) {
74
+ fluxo.quadro(quadro);
75
+ } else if (ABRE_FLUXO.has(quadro.tipo) && this.aoAbrir) {
76
+ this.aoAbrir(quadro);
77
+ }
78
+ // quadro de id que já acabou (janela atrasada, corpo depois do abortar): ignorado
79
+ }
80
+
81
+ #fechou() {
82
+ const fluxos = [...this.fluxos.values()];
83
+ this.fluxos.clear();
84
+ for (const fluxo of fluxos) fluxo.fechou?.();
85
+ this.emit('fechou');
86
+ }
87
+ }
package/lib/fluxo.js ADDED
@@ -0,0 +1,100 @@
1
+ // Controle de fluxo: o corpo nunca fica inteiro em memória.
2
+ //
3
+ // Por id: quem manda tem JANELA_INICIAL bytes de crédito; o receptor devolve o crédito
4
+ // (quadro JANELA) só quando o pedaço foi entregue ao destino (o callback do write),
5
+ // então um visitante lento ou um app local lento seguram só o próprio fluxo.
6
+ // No canal inteiro: a fonte pausa quando o WebSocket acumula mais de ALTO bytes no
7
+ // buffer de saída e retoma abaixo de BAIXO.
8
+ import { PEDACO_MAX, TIPO } from './protocolo.js';
9
+
10
+ export const JANELA_INICIAL = 2 * 1024 * 1024;
11
+ export const ALTO = 4 * 1024 * 1024;
12
+ export const BAIXO = 1024 * 1024;
13
+ const ESPERA_MS = 10;
14
+
15
+ export class Remetente {
16
+ constructor(canal, id, tipoCorpo, tipoFim) {
17
+ this.canal = canal;
18
+ this.id = id;
19
+ this.tipoCorpo = tipoCorpo;
20
+ this.tipoFim = tipoFim;
21
+ this.saldo = JANELA_INICIAL;
22
+ this.fonte = null;
23
+ this.parado = false;
24
+ this.encerrado = false;
25
+ this.timer = null;
26
+ }
27
+
28
+ ligar(fonte) {
29
+ this.fonte = fonte;
30
+ fonte.on('data', (pedaco) => this.#dados(pedaco));
31
+ fonte.on('end', () => {
32
+ if (!this.encerrado) this.canal.enviar(this.tipoFim, this.id);
33
+ });
34
+ }
35
+
36
+ credito(n) {
37
+ this.saldo += n;
38
+ this.#tentarRetomar();
39
+ }
40
+
41
+ encerrar() {
42
+ this.encerrado = true;
43
+ clearTimeout(this.timer);
44
+ this.timer = null;
45
+ }
46
+
47
+ #dados(pedaco) {
48
+ if (this.encerrado) return;
49
+ for (let i = 0; i < pedaco.length; i += PEDACO_MAX) {
50
+ const parte = pedaco.subarray(i, i + PEDACO_MAX);
51
+ this.canal.enviar(this.tipoCorpo, this.id, parte);
52
+ this.saldo -= parte.length;
53
+ }
54
+ if (this.saldo <= 0 || this.canal.bufferedAmount > ALTO) {
55
+ if (!this.parado) {
56
+ this.parado = true;
57
+ this.fonte.pause();
58
+ }
59
+ this.#tentarRetomar();
60
+ }
61
+ }
62
+
63
+ #tentarRetomar() {
64
+ if (!this.parado || this.encerrado || this.saldo <= 0) return;
65
+ if (this.canal.bufferedAmount > BAIXO) {
66
+ if (!this.timer) {
67
+ this.timer = setTimeout(() => {
68
+ this.timer = null;
69
+ this.#tentarRetomar();
70
+ }, ESPERA_MS);
71
+ }
72
+ return;
73
+ }
74
+ this.parado = false;
75
+ this.fonte.resume();
76
+ }
77
+ }
78
+
79
+ // Entrega um pedaço ao destino e devolve o crédito quando ele saiu.
80
+ export function entregar(destino, pedaco, canal, id) {
81
+ if (destino.destroyed || destino.writableEnded) return;
82
+ destino.write(pedaco, (erro) => {
83
+ if (!erro) canal.enviar(TIPO.JANELA, id, pedaco.length);
84
+ });
85
+ }
86
+
87
+ // Mensagens de WebSocket: pausa a fonte enquanto o canal está cheio.
88
+ export function segurarWs(ws, canal) {
89
+ if (canal.bufferedAmount <= ALTO || ws.isPaused) return;
90
+ ws.pause();
91
+ const conferir = () => {
92
+ if (ws.readyState > 1) return;
93
+ if (canal.aberto && canal.bufferedAmount > BAIXO) {
94
+ setTimeout(conferir, ESPERA_MS);
95
+ return;
96
+ }
97
+ ws.resume();
98
+ };
99
+ setTimeout(conferir, ESPERA_MS);
100
+ }
@@ -0,0 +1,96 @@
1
+ // Os quadros que atravessam o WebSocket entre o servidor e o agente.
2
+ //
3
+ // Cada quadro binário: 1 byte de tipo, 4 bytes de id (uint32 BE) e o payload.
4
+ // - inícios (REQ_INICIO, RES_INICIO, WS_ABRIR), WS_FECHAR e ABORTAR: JSON;
5
+ // - corpos (REQ_CORPO, RES_CORPO): bytes, em pedaços de até 64 KiB;
6
+ // - fins (REQ_FIM, RES_FIM): vazios;
7
+ // - WS_DADO: 1 byte (1 = binário, 0 = texto) e os bytes da mensagem;
8
+ // - JANELA: uint32 BE, os bytes que o receptor já entregou ao destino daquele id
9
+ // (o crédito do controle de fluxo por id, ver lib/fluxo.js).
10
+ // As mensagens de controle (hello, ready, erro) vão em quadros de texto JSON.
11
+
12
+ export const TIPO = Object.freeze({
13
+ REQ_INICIO: 1,
14
+ REQ_CORPO: 2,
15
+ REQ_FIM: 3,
16
+ RES_INICIO: 4,
17
+ RES_CORPO: 5,
18
+ RES_FIM: 6,
19
+ WS_ABRIR: 7,
20
+ WS_DADO: 8,
21
+ WS_FECHAR: 9,
22
+ ABORTAR: 10,
23
+ JANELA: 11,
24
+ });
25
+
26
+ export const NOME_DO_TIPO = Object.freeze(
27
+ Object.fromEntries(Object.entries(TIPO).map(([nome, n]) => [n, nome])),
28
+ );
29
+
30
+ export const CABECALHO = 5;
31
+ export const PEDACO_MAX = 64 * 1024;
32
+ const ID_MAX = 0xffffffff;
33
+
34
+ const TIPOS_JSON = new Set([TIPO.REQ_INICIO, TIPO.RES_INICIO, TIPO.WS_ABRIR, TIPO.WS_FECHAR, TIPO.ABORTAR]);
35
+
36
+ function corpoDe(tipo, payload) {
37
+ if (TIPOS_JSON.has(tipo)) return Buffer.from(JSON.stringify(payload ?? {}), 'utf8');
38
+ if (tipo === TIPO.JANELA) {
39
+ if (!Number.isInteger(payload) || payload < 0 || payload > ID_MAX) {
40
+ throw new RangeError(`janela inválida: ${payload}`);
41
+ }
42
+ const b = Buffer.allocUnsafe(4);
43
+ b.writeUInt32BE(payload, 0);
44
+ return b;
45
+ }
46
+ if (tipo === TIPO.WS_DADO) {
47
+ const dados = Buffer.isBuffer(payload?.dados) ? payload.dados : Buffer.from(payload?.dados ?? '');
48
+ const b = Buffer.allocUnsafe(1 + dados.length);
49
+ b[0] = payload?.binario ? 1 : 0;
50
+ dados.copy(b, 1);
51
+ return b;
52
+ }
53
+ if (payload == null) return Buffer.alloc(0);
54
+ if (Buffer.isBuffer(payload)) return payload;
55
+ if (payload instanceof Uint8Array) return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
56
+ throw new TypeError(`payload de ${NOME_DO_TIPO[tipo]} tem de ser bytes`);
57
+ }
58
+
59
+ export function codificar(tipo, id, payload) {
60
+ if (!NOME_DO_TIPO[tipo]) throw new RangeError(`tipo desconhecido: ${tipo}`);
61
+ if (!Number.isInteger(id) || id < 0 || id > ID_MAX) throw new RangeError(`id inválido: ${id}`);
62
+ const corpo = corpoDe(tipo, payload);
63
+ const quadro = Buffer.allocUnsafe(CABECALHO + corpo.length);
64
+ quadro[0] = tipo;
65
+ quadro.writeUInt32BE(id, 1);
66
+ corpo.copy(quadro, CABECALHO);
67
+ return quadro;
68
+ }
69
+
70
+ export function decodificar(dados) {
71
+ const buf = Buffer.isBuffer(dados) ? dados : Buffer.from(dados);
72
+ if (buf.length < CABECALHO) throw new RangeError(`quadro curto: ${buf.length} bytes`);
73
+ const tipo = buf[0];
74
+ if (!NOME_DO_TIPO[tipo]) throw new RangeError(`tipo desconhecido: ${tipo}`);
75
+ const id = buf.readUInt32BE(1);
76
+ const resto = buf.subarray(CABECALHO);
77
+ let payload = resto;
78
+ if (TIPOS_JSON.has(tipo)) {
79
+ payload = resto.length ? JSON.parse(resto.toString('utf8')) : {};
80
+ } else if (tipo === TIPO.JANELA) {
81
+ if (resto.length !== 4) throw new RangeError('janela com tamanho errado');
82
+ payload = resto.readUInt32BE(0);
83
+ } else if (tipo === TIPO.WS_DADO) {
84
+ if (resto.length < 1) throw new RangeError('ws-dado sem marca');
85
+ payload = { binario: resto[0] === 1, dados: resto.subarray(1) };
86
+ }
87
+ return { tipo, id, payload };
88
+ }
89
+
90
+ // Os códigos que podem ir num quadro de fechamento de WebSocket (RFC 6455, 7.4).
91
+ export function codigoEnviavel(codigo) {
92
+ return (
93
+ Number.isInteger(codigo) &&
94
+ ((codigo >= 1000 && codigo <= 1003) || (codigo >= 1007 && codigo <= 1014) || (codigo >= 3000 && codigo <= 4999))
95
+ );
96
+ }