Skip to content

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.

ts
/** 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,
  };
}
js
/** 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,
  };
}
sh
# 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":{…}}]}
ts
/**
 * 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);
  };
}
js
/**
 * 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);
  };
}
sh
# 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
done

O 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/manifesto

Uma 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.

ts
/** 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;
  }
}
js
/**
 * 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 sinal

O 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).

sh
# 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.