@conduito/port
v0.1.0
Published
The MessagePort transport for Conduito — Web Workers, SharedWorkers, MessageChannel and Node worker_threads, with transferables and respawn.
Readme
@conduito/port
O transporte do Conduito sobre MessagePort — Web Workers, SharedWorkers, MessageChannel e o
worker_threads do Node. O mesmo protocolo, o mesmo Endpoint e as mesmas capabilities que o
@conduito/frame roda sobre iframe, agora atravessando uma
thread.
Filosofia: o contrato não muda quando o carrier muda. Uma capability declarada uma vez é chamada do mesmo jeito se o outro lado é um iframe cross-origin, um worker na mesma aba, ou uma thread do Node.
Roda nos dois mundos, com um código só. O pacote não importa node:worker_threads nem lib DOM: ele
descreve o carrier estruturalmente e detecta em runtime se o objeto fala o estilo EventTarget
(addEventListener, browser) ou EventEmitter (on/off, Node). O bundle publicado tem exatamente um
import — @conduito/core.
pnpm add @conduito/portUso
Quem tem a porta (main thread)
import { connect } from "@conduito/port";
const worker = new Worker(new URL("./app.js", import.meta.url), {
type: "module",
});
const { endpoint, connected } = connect(worker, {
onConnect: async (endpoint) => {
const { capabilities } = await endpoint.discover(); // o "db pull"
renderSidebar(capabilities);
},
});
const routes = await endpoint.request("routes", "list");Quem está dentro (o worker)
import { listen } from "@conduito/port";
import { routesCap } from "./capabilities";
listen({
onConnect(app) {
app.expose(routesCap);
},
});Sem argumento, o listen adota o self do worker. No Node o parentPort não é global — passe-o:
import { parentPort } from "node:worker_threads";
listen(parentPort!, { onConnect: (app) => app.expose(routesCap) });O que mais cabe aqui
| carrier | main thread | do outro lado |
| --------------------- | ------------------------ | --------------------------------------------------------- |
| Web Worker | connect(worker) | listen({ onConnect }) |
| SharedWorker | connect(shared.port) | self.onconnect = e => listen(e.ports[0], { onConnect }) |
| MessageChannel | connect(channel.port1) | listen(channel.port2, { onConnect }) |
| Node worker_threads | connect(worker) | listen(parentPort!, { onConnect }) |
O handshake (e por que ele quase não aparece)
Os dois lados se anunciam: o connect pinga HANDSHAKE até ser respondido, e o listen manda um HELLO
espontâneo assim que instala. Fecha por qualquer um dos dois caminhos, então a ordem em que você monta as
pontas não importa — e o ping de segurança quase nunca chega a repetir.
O listen, diferente do @conduito/frame, não espera nada pra chamar seu onConnect: não há origem a
validar, a porta já é a porta. Suas capabilities sobem primeiro, o anúncio vai depois.
O que fica enfileirado enquanto o par não respondeu não se perde: sai na conexão, em ordem. O timeout do
próprio request continua limitando a espera, e a fila tem teto (queueLimit, default 100) — além dele o
envio falha com EQUEUEFULL.
O worker morreu
Worker não recarrega como um iframe: ele morre (terminate(), um erro fatal) e você cria outro. O
Endpoint é um só pra vida toda da conexão, então o que você registrou (handle, on, expose)
sobrevive — o que morre é o outro lado.
const conn = connect(worker, {
onConnect: (endpoint) => endpoint.notify("theme", "apply", theme), // a cada conexão
onDisconnect: (reason, cause) => log(reason, cause), // "died" | "respawn" | "dispose"
});
// criou outro worker? aponte a conexão pra ele — mesmo endpoint, onConnect de novo
conn.rebind(new Worker(url, { type: "module" }));
// sabe que morreu e ainda não há substituto?
conn.disconnect(); // pendências rejeitadas (EDISCONNECTED), novos envios vão pra filaUm request já enviado quando o worker caiu rejeita na hora com EDISCONNECTED — não depois do timeout
inteiro. Ele pode ter executado; o transporte não repete.
Por padrão (watch: true) a conexão observa os eventos em que o carrier anuncia a própria morte — error
(web e Node), exit e close — e desconecta sozinha, passando o erro em cause.
[!WARNING] No Node, um
Workersem nenhum listener deerrorderruba o processo. Comwatch: trueeste pacote registra um — o que é o comportamento útil (você recebe emonDisconnect), mas silencia esse crash. Se você já trataerrorpor conta própria, ou depende do crash, usewatch: false.
Transferables
Transferir um buffer o esvazia do lado de quem enviou, então nada viaja assim por dedução — você marca:
import { transfer } from "@conduito/port";
await endpoint.request("image", "rotate", transfer({ pixels, angle }));
// `pixels` agora está detached aqui: foi movido, não copiado
// do lado do worker, o mesmo vale pra resposta
app.handle("image", "rotate", (p) => transfer(render(p)));transfer(valor) devolve o próprio valor (dá pra embrulhar o argumento na chamada) e descobre sozinho o que
é transferível lá dentro: ArrayBuffer, views tipadas (transfere o buffer delas), MessagePort,
ImageBitmap, streams. Passe a lista explícita quando quiser controlar exatamente o que se move:
transfer(payload, [pixels]). Sem a marca, o structured clone copia — que é o default seguro.
Segurança — o que muda em relação ao iframe
Não há origem aqui, e este pacote não finge que há. Um worker não tem origin pra checar: a confiança
vem de quem instanciou o carrier — você passou a URL do script —, não de quem fala. O
allowedOrigins obrigatório do @conduito/frame, que é a resposta à pergunta "quem é o par?", não tem
análogo nesta topologia.
O que o transporte garante é o envelope: só uma Message válida do Conduito sobe pro Endpoint, e o
resto do tráfego da porta é ignorado — então dividir a porta com outra biblioteca não quebra nada. As outras
duas perguntas de segurança continuam iguais: o que está exposto é o que você registra com expose, e a
sessão é decisão de cada handler.
Um SharedWorker atende várias páginas da mesma origem: cada e.ports[0] do onconnect é uma conexão
distinta, e cada uma precisa do seu listen. O que você expõe num não vaza pro outro — são Endpoints
separados.
API
| export | o quê |
| ------------------------------------- | --------------------------------------------------------------------- |
| connect(port, opts?) | dirige a ponta que você segura → Connection |
| listen(opts) / listen(port, opts) | o lado de dentro → Listener (sem porta, adota o self do worker) |
| transfer(value, list?) | marca o valor pra ser movido em vez de copiado |
| transferablesOf(value) | a lista marcada num valor (para quem escreve transporte próprio) |
| bind(port, type, handler) | assina um evento no estilo que o carrier falar; devolve o unsubscribe |
| type PortLike | a forma estrutural que este pacote dirige |
| type DisconnectReason | "respawn" | "died" | "dispose" |
ConnectOptions: { handshakeInterval?, queueLimit?, watch?, endpoint?, onConnect?, onDisconnect? } —
endpoint são as EndpointOptions do core (defaultTimeout, onError).
Connection: { endpoint, connected, rebind(port), disconnect(), dispose() }.
Listener: { endpoint, dispose() }.
Erros codados que este transporte emite: EPORT (o alvo não tem postMessage), ETRANSFER (um primitivo
foi marcado pra transferência) e EQUEUEFULL.
Internals: docs/ARCHITECTURE.md (no repositório).
