Tempo real: saber que algo mudou
Três jeitos, para três públicos, com o mesmo nos três: o app assina uma fila de eventos e lê a cada segundo e meio, a tela do ouve o WebSocket do Geohub central como sinal para reler, e o recebe o . O primeiro e o terceiro fazem parte do contrato e valem para qualquer
, o central ou o local do ; o segundo é do central.O app: ouvir os eventos do plugin
O cliente remoto do app assina os eventos do plugin e entrega cada um pelo .on(tipo, …). Por baixo é polling por inscrição, e o evento é só o sinal para reler a lista.
/** Liga os dois hubs remotos com a sessão do policial. */
export async function ligarHubs(conexao: ConexaoDosHubs): Promise<HubsDoApp> {
const polling = conexao.polling ?? { aCada: milissegundos(1500) };
const intervaloDeEventos = polling === 'desligado' ? 0 : polling.aCada;
const jornada = jornadaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
const ocorrencia = ocorrenciaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
// `iniciar()` só liga o polling DESTE cliente; o plugin é do servidor.
await Promise.all([jornada.iniciar(), ocorrencia.iniciar()]);
return {
desligar: async () => {
await Promise.all([jornada.parar(), ocorrencia.parar()]);
},
jornada,
ocorrencia,
};
}/** Liga os dois hubs remotos com a sessão do policial. */
export async function ligarHubs(conexao) {
const polling = conexao.polling ?? { aCada: milissegundos(1500) };
const intervaloDeEventos = polling === 'desligado' ? 0 : polling.aCada;
const jornada = jornadaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
const ocorrencia = ocorrenciaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
// `iniciar()` só liga o polling DESTE cliente; o plugin é do servidor.
await Promise.all([jornada.iniciar(), ocorrencia.iniciar()]);
return {
desligar: async () => {
await Promise.all([jornada.parar(), ocorrencia.parar()]);
},
jornada,
ocorrencia,
};
}# A inscrição é um uuid seu; a primeira leitura vem vazia e ela expira após 60 s sem leitura
curl "https://geohub.exemplo.gov.br/plugins/ocorrencia/eventos?inscricao=$INSCRICAO" -H "Authorization: Bearer $TOKEN"
# {"eventos":[{"tipo":"ocorrencia:despachada","payload":{…},"origem":{…}}]}/**
* Avisa quando algo do atendimento mudou, para a tela reler.
*
* O evento é o sinal, não a fonte: o `ocorrencia:despachada` diz que nasceu
* um despacho, e é o `listarDespachosDaGuarnicao` que diz se é desta
* guarnição. A função devolvida cancela as assinaturas.
*/
export function aoMudarOAtendimento(
hubs: HubsDoApp,
reler: () => void,
): () => void {
const sinal = (): void => {
reler();
};
hubs.ocorrencia.on('ocorrencia:despachada', sinal);
hubs.ocorrencia.on('ocorrencia:despacho-mudou-status', sinal);
hubs.ocorrencia.on('ocorrencia:mudou-status', sinal);
hubs.jornada.on('jornada:guarnicao-mudou-status', sinal);
return () => {
hubs.ocorrencia.off('ocorrencia:despachada', sinal);
hubs.ocorrencia.off('ocorrencia:despacho-mudou-status', sinal);
hubs.ocorrencia.off('ocorrencia:mudou-status', sinal);
hubs.jornada.off('jornada:guarnicao-mudou-status', sinal);
};
}/**
* Avisa quando algo do atendimento mudou, para a tela reler.
*
* O evento é o sinal, não a fonte: o `ocorrencia:despachada` diz que nasceu
* um despacho, e é o `listarDespachosDaGuarnicao` que diz se é desta
* guarnição. A função devolvida cancela as assinaturas.
*/
export function aoMudarOAtendimento(hubs, reler) {
const sinal = () => {
reler();
};
hubs.ocorrencia.on('ocorrencia:despachada', sinal);
hubs.ocorrencia.on('ocorrencia:despacho-mudou-status', sinal);
hubs.ocorrencia.on('ocorrencia:mudou-status', sinal);
hubs.jornada.on('jornada:guarnicao-mudou-status', sinal);
return () => {
hubs.ocorrencia.off('ocorrencia:despachada', sinal);
hubs.ocorrencia.off('ocorrencia:despacho-mudou-status', sinal);
hubs.ocorrencia.off('ocorrencia:mudou-status', sinal);
hubs.jornada.off('jornada:guarnicao-mudou-status', sinal);
};
}# A cada 1,5 s, até receber um evento de ocorrência; depois, reler os despachos
while true; do
curl -s "https://geohub.exemplo.gov.br/plugins/ocorrencia/eventos?inscricao=$INSCRICAO" -H "Authorization: Bearer $TOKEN"
sleep 1.5
doneO envelope é o mesmo nos três: o da página o CAD recebe o que aconteceu.
A. Polling por inscrição (app, demo)
Cada plugin tem, na base dele, o transporte de invocação, que os clientes remotos do navegador usam no lugar das rotas de :
POST /plugins/ocorrencia/invocar {"verbo":"aceitarDespacho","args":["…"],"solicitacaoId":"…"}
GET /plugins/ocorrencia/eventos?inscricao=<id> → 200 {"eventos":[ {envelope}, {envelope} ]}
POST /plugins/ocorrencia/eventos/encerrar {"inscricao":"<id>"}
GET /plugins/ocorrencia/manifestoUma fila por inscrição; o id da inscrição é seu (um uuid). A primeira leitura vem vazia, e a inscrição expira após 60 s sem leitura; o demo lê a cada 1,5 s. O mesmo vale para /plugins/jornada. O que você vê depende da sessão: o policial vê os eventos de (piso ocorrencia.consultar) e os de da própria .
B. WebSocket do Geohub central (sinal para o app)
O WebSocket do plugin é a fase 3 da RDT-027 e ainda não existe. O que o app usa hoje é o WebSocket do Geohub central, com o mesmo access token do HTTP, assinando as coleções despacho e guarnicao. O payload que chega não diz de que guarnição é, então o app o trata como sinal e relê a lista de pela API. É assim que "aceito por um, aceito por todos" aparece na tela dos outros integrantes.
/** O mínimo de um WebSocket de que o sinal precisa. */
export interface CanalDeSinal {
onmessage: ((evento: { data: string }) => void) | null;
onopen: (() => void) | null;
send(dados: string): void;
}
export interface OpcoesDoSinal {
/** Abre o canal; sem ele, o `WebSocket` do navegador. */
readonly abrir?: (endereco: string) => CanalDeSinal;
/** O que fazer a cada sinal: reler os despachos pela API. */
readonly aoSinal: () => void;
/** A raiz do Geohub (`https://geohub.exemplo.gov.br`). */
readonly geohub: string;
/** O mesmo access token do HTTP. */
readonly token: string;
}
export const COLECOES_DO_SINAL = ['despacho', 'guarnicao'] as const;
export const EVENTOS_DO_SINAL = ['create', 'update', 'delete'] as const;
/** `https` vira `wss`, o caminho é `/websocket` e o token vai na query. */
export function enderecoDoSinal(base: string, token: string): string {
const endereco = new URL('/websocket', base);
endereco.protocol = endereco.protocol === 'http:' ? 'ws:' : 'wss:';
endereco.searchParams.set('access_token', token);
return endereco.toString();
}
/** O `WebSocket` do navegador, reduzido ao que o sinal usa. */
export function abrirNoNavegador(endereco: string): CanalDeSinal {
const socket = new WebSocket(endereco);
const canal: CanalDeSinal = {
onmessage: null,
onopen: null,
send: (dados) => {
socket.send(dados);
},
};
socket.onopen = () => canal.onopen?.();
socket.onmessage = (evento) =>
canal.onmessage?.({ data: String(evento.data) });
return canal;
}
/** O que o painel manda pelo fio: o heartbeat, a assinatura, e o resto. */
type MensagemDoPainel =
| { readonly type: 'ping'; readonly event?: never }
| { readonly type: 'subscription'; readonly event: string }
| { readonly type: 'auth' | (string & {}); readonly event?: never };
function ehMensagemDoPainel(valor: unknown): valor is MensagemDoPainel {
return (
typeof valor === 'object' &&
valor !== null &&
'type' in valor &&
typeof valor.type === 'string'
);
}
export class SinalDoPainel {
constructor(private readonly opcoes: OpcoesDoSinal) {}
ouvir(): CanalDeSinal {
const { abrir = abrirNoNavegador, aoSinal, geohub, token } = this.opcoes;
const canal = abrir(enderecoDoSinal(geohub, token));
canal.onopen = () => {
for (const collection of COLECOES_DO_SINAL) {
for (const event of EVENTOS_DO_SINAL) {
canal.send(
JSON.stringify({
collection,
event,
type: 'subscribe',
uid: `${collection}:${event}`,
}),
);
}
}
};
canal.onmessage = ({ data }) => {
const mensagem: unknown = JSON.parse(data);
if (!ehMensagemDoPainel(mensagem)) return;
if (mensagem.type === 'ping') {
canal.send(JSON.stringify({ type: 'pong' }));
return;
}
if (mensagem.type === 'subscription' && mensagem.event !== 'init') {
aoSinal();
}
};
return canal;
}
}/**
* O WebSocket do Geohub central como **sinal** para o app.
*
* O WebSocket do plugin ainda não existe (fase 3 da RDT-027). O que o app usa
* é o do painel, com o mesmo access token do HTTP, assinando `despacho` e
* `guarnicao`. O que chega não diz de que guarnição é, então o app não lê o
* payload: trata a mensagem como sinal e relê os despachos pela API.
*/
export const COLECOES_DO_SINAL = ['despacho', 'guarnicao'];
export const EVENTOS_DO_SINAL = ['create', 'update', 'delete'];
/** `https` vira `wss`, o caminho é `/websocket` e o token vai na query. */
export function enderecoDoSinal(base, token) {
const endereco = new URL('/websocket', base);
endereco.protocol = endereco.protocol === 'http:' ? 'ws:' : 'wss:';
endereco.searchParams.set('access_token', token);
return endereco.toString();
}
/** O `WebSocket` do navegador, reduzido ao que o sinal usa. */
export function abrirNoNavegador(endereco) {
const socket = new WebSocket(endereco);
const canal = {
onmessage: null,
onopen: null,
send: (dados) => {
socket.send(dados);
},
};
socket.onopen = () => canal.onopen?.();
socket.onmessage = (evento) =>
canal.onmessage?.({ data: String(evento.data) });
return canal;
}
function ehMensagemDoPainel(valor) {
return (
typeof valor === 'object' &&
valor !== null &&
'type' in valor &&
typeof valor.type === 'string'
);
}
export class SinalDoPainel {
opcoes;
constructor(opcoes) {
this.opcoes = opcoes;
}
ouvir() {
const { abrir = abrirNoNavegador, aoSinal, geohub, token } = this.opcoes;
const canal = abrir(enderecoDoSinal(geohub, token));
canal.onopen = () => {
for (const collection of COLECOES_DO_SINAL) {
for (const event of EVENTOS_DO_SINAL) {
canal.send(
JSON.stringify({
collection,
event,
type: 'subscribe',
uid: `${collection}:${event}`,
}),
);
}
}
};
canal.onmessage = ({ data }) => {
const mensagem = JSON.parse(data);
if (!ehMensagemDoPainel(mensagem)) return;
if (mensagem.type === 'ping') {
canal.send(JSON.stringify({ type: 'pong' }));
return;
}
if (mensagem.type === 'subscription' && mensagem.event !== 'init') {
aoSinal();
}
};
return canal;
}
}
// #endregion sinalO canal é o WebSocket do navegador reduzido ao que o sinal usa, e por isso o teste o substitui por um canal falso: assinar as seis combinações, responder pong ao ping, ignorar a carga inicial e reler a cada mudança.
A resposta do subscribe ecoa o uid; a correlação é por ele, porque a mensagem não traz collection. O que o socket entrega é o que o papel do token pode ler.
C. Webhook ao integrador
POST https://cad.exemplo.gov.br/geohub/eventos (o callback_url do cadastro)
Content-Type: application/json
X-Geohub-Evento-Id: 0199bb3e-0000-7000-8000-00000000004a
X-Geohub-Timestamp: 1761912251 (epoch em segundos; recuse fora de ±5 min)
X-Geohub-Assinatura: sha256=<hex do HMAC-SHA256(segredo, "1761912251." + corpo cru)>
X-Geohub-Agregado: ocorrencia:0199… (ocorrencia:, guarnicao:, jornada:, pausa:, solicitacao:)
X-Geohub-Sequencia: 4 (1, 2, 3… sem buraco, por integrador e por agregado)
{ …o envelope… }Responda 204 (qualquer 2xx serve, inclusive para repetido e para eco).
# Conferir a assinatura no shell
printf '%s.%s' "$TS" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO"As obrigações do receptor e as garantias de entrega estão em o CAD recebe o que aconteceu.