Daemon TypeScript de longa execução para monitoramento e captura de streams
ao vivo em plataformas públicas. Detecta transições online/offline por
polling de API pública, dispara streamlink como child process pra baixar
o .ts bruto, e mantém sidecar de metadados durável ao lado de cada
gravação. Controlado por um CLI separado que fala com o daemon via unix
socket — dois processos, um protocolo compartilhado, tipos ponta-a-ponta.
O escopo do domínio é intencionalmente pequeno — o valor do projeto está nas decisões arquiteturais que aguentam o crescimento sem virar bola de neve: hexagonal com dois driving adapters (IPC e EventBus), use cases como unidade de intenção, IPC tipado com discriminated union do zod, durabilidade em duas fontes (SQLite + sidecar JSON), 3 tiers de teste com custos crescentes.
| Camada | Tecnologia | Justificativa |
|---|---|---|
| Runtime | Bun 1.3 | Bun.spawn pra child processes, bun:sqlite nativo, TS sem transpiler no dev loop, Bun.file/Bun.serve como stdlib expandida |
| Persistência estruturada | SQLite via Drizzle ORM 1.0-rc | Migrations tipadas, SQL explícito quando precisa, zero runtime overhead — só typed query builder |
| Persistência durável de mídia | Filesystem + meta.json sidecar por gravação | Redundância intencional: DB é a fonte queryable, sidecar é a fonte auto-suficiente (.mp4 + meta.json no mesmo dir) |
| Validação de I/O | zod 4 | Discriminated unions no fio (schema.parse em input do socket, output da API externa) — compile-time + runtime alinhados |
| IPC entre processos | Unix domain socket + NDJSON framing | Um daemon + N clientes CLI concorrentes; length-prefix seria overkill pro volume, delimitador de linha é depurável |
| CLI | commander 15 | Registro modular por comando, subparsing built-in |
| Test runner | bun test | Auto-discovery de *.spec.ts, mesmo runtime da produção — zero diferença de comportamento entre teste e app |
| Formatter + linter | Biome 2 | Substitui prettier + eslint com 1 config, roda em ~50ms em todo o repo |
| Monorepo orchestration | Turborepo 2.10 | Task pipelines com cache, dependsOn: ["^build"] pra ordering correto |
Convenção de imports: @/ mapeia pra apps/daemon/src/. Cross-package
usa @repo/ipc (workspace protocol).
Diagramas C4 (Context, Container e o fluxo assíncrono mais espinhoso do
sistema) em docs/architecture/overview.md.
ChannelRepository, TwitchRecorder, MediaStorage,
StreamMetaStorage, TwitchClient) e do EventBus. Testado
in-memory com fakes.useCase.execute().PostgresChannelRepository, zero
toque em use case.bus.subscribe(...) e passa handler pro IpcServer.
DI manual — sem container mágico.Um use case não sabe quem o chamou:
Isso mantém o núcleo neutro: adicionar um comando force-record que
reusa StartRecordingUseCase = uma nova entrada no schema IPC + um handler
no router. Nada muda no use case.
O bus roteia por identidade da classe do evento (event.constructor como
chave no Map), awaita subscribers sequencialmente, isola erros por
handler (um handler bugado não derruba os outros nem o publish()).
O publisher decide se awaita ou não — decisão arquitetural com consequências reais em latência e correção de shutdown. Documentado em notes/events-publisher-await-semantics.md.
A escolha "local → Emitter genérico → EventBus central" tem 3 estágios com trade-offs distintos, discutidos em notes/events-evolution.md.
Result<L, R>Cada verbo do sistema é uma classe com execute(params). Deps injetadas
via constructor. Retorna Result<ErrorClass, SuccessData> — não
throw, não callback. Caller (subscriber ou IPC handler) inspeciona
isSuccess()/isFailure() e decide o que fazer.
Exemplo:
Erros propagam via Error.cause (padrão ES2022) — subscriber pode
inspecionar a causa raiz sem parsing de mensagem.
O package @repo/ipc é a fonte única de verdade do
contrato — daemon e CLI ambos importam daí:
No lado do daemon, IpcRouter faz dispatch tipado via Record<cmd, Handler<cmd>>
com mapped types — se um comando novo entra no schema, TypeScript quebra
até você registrar o handler. Impossível esquecer.
O framing usa NDJSON (linha-por-mensagem) em vez de length-prefix —
depurável com nc -U ou socat, custo baixo pro volume esperado. Ver
packages/ipc/src/framing.ts.
Socket path segue XDG: $XDG_RUNTIME_DIR/stream-scrapper.sock com
fallback pra tmpdir(). Override via STREAM_SCRAPPER_SOCKET env pra
isolamento em testes E2E.
Cada gravação escreve um meta.json ao lado do stream.ts no filesystem
— auto-suficiente, versionado com meta_schema_version, atualizado
atomicamente via writeFileSync(tmp) + renameSync(tmp, dest).
Motivação: o DB pode ser reconstruído a partir dos sidecars (rebuild via scan do filesystem). Se o DB corromper ou for perdido, os vídeos continuam auto-descritos. Se o sidecar corromper, o DB é fonte canônica. Redundância deliberada — o custo é uma escrita extra por gravação, o ganho é resiliência contra 2 modos de falha diferentes.
Update do sidecar acontece quando o streamlink morre — o recorder
publica RecordingFinishedEvent ou RecordingFailedEvent no bus, o
FinalizeRecordingUseCase recebe e reescreve o meta.json com
endedAt, bytes, status. Ver
notes/recorder-implementation.md.
Não uso DI container. main.ts é ~130 linhas que instanciam tudo em
ordem topológica de dependências, no formato "banda larga acima, ponta
fina abaixo":
Trade-off consciente: perde-se auto-wiring de containers como tsyringe,
mas ganha-se transparência total — pra saber quem instancia o quê, uma
leitura sequencial do arquivo responde.
| Tier | Extensão | Runtime | Escopo | Roda por padrão? |
|---|---|---|---|---|
| Unit | *.spec.ts | bun test (auto-discovery) | Use case + fake driven adapters | ✅ |
| Integration | *.integration-spec.ts | find ... -exec bun test | IpcServer real + DB in-memory + fake TwitchClient | ✅ via test:integration |
| E2E smoke | scripts/smoke-e2e.ts | bun scripts/smoke-e2e.ts | Daemon como subprocess + CLI real + Twitch real | ❌ manual, pré-release |
Unit tests usam fakes locais (FakeTwitchClient, FakeRecorder) em
src/test/ — implementam a mesma interface das versões reais, sem
mocking framework. Um erro no fake é um erro de TypeScript.
As quatro decisões abaixo com uma alternativa plausível descartada têm ADR
formal em docs/decisions/, com contexto, opções
consideradas e consequências completas:
bun:sqlite nativo.proc.exited captura o
código e a gente lida. SIGTERM deixa o .ts fechar limpo; SIGKILL
fallback em 10s protege contra travados.Trade-offs menores, sem ADR próprio (decisão simples ou fácil de reverter):
.drizzle/ versionadas no
git.z.coerce.date() em vez de reviver manual: JSON.parse devolve
string, zod hidrata pra Date — evita new Date(str) espalhado.Pré-requisitos: Bun 1.3+, streamlink instalado no PATH (ou STREAMLINK_BIN_PATH
apontando pro binário).
Testes:
Migrations do DB:
Estado atual: pipeline completo funciona ponta-a-ponta (detectar live → gravar → fechar meta.json com sucesso ou falha). Próximos passos:
.ts → .mp4: job assíncrono via ffmpeg reagindo a
RecordingFinishedEvent. O .ts é o formato bruto que o streamlink
entrega; MP4 é o que a maioria dos players consome bem.meta.json fecha em finished,
o stream.durationSeconds no DB continua nulo. Adicionar método
updateStreamOnFinish no StreamRepository.RecordingFinished/Failed que dispara Discord/Slack. O EventBus foi
desenhado exatamente pra isso — 1 linha de bus.subscribe no
composition root, zero toque em produtor.streamlink concorrentes. Desenho da solução (não implementado ainda)
em docs/design/001-teto-de-gravacoes-simultaneas.md.Decisões arquiteturais com contexto histórico completo (por que foi
feito assim, alternativas consideradas, trade-offs aceitos) vivem em
apps/daemon/notes/:
bus.publish(), e por que a decisão é do
publisher (não do bus).ts,
auth do providertry/catch no handleExitstream-scrapper/
├── apps/
│ ├── daemon/ ← processo long-running, single instance
│ │ ├── src/
│ │ │ ├── @errors/ ← classes de erro tipadas (ErrorOptions.cause)
│ │ │ ├── @shared/events/ ← EventBus in-process + contrato base
│ │ │ ├── application/ ← núcleo hexagonal
│ │ │ │ ├── models/ ← tipos de domínio puros
│ │ │ │ ├── repositories/ ← interfaces (ports)
│ │ │ │ └── use-cases/ ← unidade de intenção (1 classe = 1 caso)
│ │ │ ├── infrastructure/ ← implementações (driven adapters)
│ │ │ │ ├── database/ ← DrizzleXxxRepository, schemas
│ │ │ │ ├── ipc/ ← IpcServer + IpcRouter (driving adapter)
│ │ │ │ ├── media-storage/ ← filesystem gateway + schema meta.json
│ │ │ │ ├── monitor/ ← poll de API + emissão de eventos
│ │ │ │ ├── recorder/ ← streamlink subprocess management
│ │ │ │ └── twitch/ ← HTTP client + zod schemas do provider
│ │ │ ├── test/ ← helpers compartilhados (fakes)
│ │ │ └── main.ts ← composition root: instância única de tudo
│ │ └── notes/ ← decisões arquiteturais versionadas
│ └── cli/ ← thin adapter: parseia argv, fala com daemon
│ └── src/
│ ├── client.ts ← IpcClient (conecta, envia, aguarda resposta)
│ ├── commands/ ← 1 arquivo por comando (ping, add-channel…)
│ └── index.ts ← commander wiring
├── packages/
│ └── ipc/ ← protocolo compartilhado — fonte única
│ └── src/
│ ├── protocol.ts ← IpcRequest/IpcResponse (zod discriminated union)
│ ├── framing.ts ← NDJSON encode + LineBuffer
│ └── socket-path.ts ← resolução XDG_RUNTIME_DIR + override env
├── scripts/
│ └── smoke-e2e.ts ← E2E real: sobe daemon isolado, roda CLI, mata tudo
├── biome.json ← formatter/linter compartilhado
├── turbo.json ← pipeline de tasks
└── package.json ← workspaces + scripts raiz Driving adapters Núcleo (application) Driven adapters
──────────────── ──────────────────── ─────────────────
CLI ──▶ IpcServer ──▶┐ ┌──▶ ChannelRepository
│ AddChannelUseCase │ (Drizzle)
│ EnableAutoRecordingUseCase │
Monitor ──▶ Bus ──▶ │─────▶StartRecordingUseCase ─────│──▶ MediaStorage
(poll de API) │ StopRecordingUseCase │ (filesystem)
│ FinalizeRecordingUseCase │
Recorder ──▶ Bus ──▶─┘ └──▶ StreamRecorder
(proc.exited) (streamlink)
TwitchClient (GQL)data/<channel>/<data>/<título>(<streamId>)/
├── stream.ts ← MPEG-TS bruto do streamlink
└── meta.json ← metadados durables (schema versionado)// Vindo do CLI (IpcServer → IpcRouter → dispatch tipado)
{ cmd: 'add-channel', username: 'x' } ──▶ addChannel.execute({ channelName: 'x' })
// Vindo do Monitor (bus.publish → subscriber → thin adapter)
new ChannelLiveEvent({...}) ──▶ startRecording.execute({...})export class StartRecordingUseCase {
constructor(private readonly props: UseCaseProps) {}
async execute(params: UseCaseParams): Promise<Result<StreamRecordingFailedError, void>> {
try {
await this.props.streamRepository.createStream({...})
const { fullPath } = this.props.storage.createStreamPath({...})
this.props.streamMetaStorage.writeStreamMeta({...})
await this.props.recorder.recordTwitchStream({...})
return success(undefined)
} catch (error) {
return failure(new StreamRecordingFailedError(channelName, { cause: error }))
}
}
}// packages/ipc/src/protocol.ts
export const IpcRequest = z.discriminatedUnion('cmd', [
PingRequest,
AddChannelRequest,
EnableAutoRecordingRequest,
])
export type IpcRequest = z.infer<typeof IpcRequest>// Infra base
const db = createDrizzle(createDatabase(config.databasePath))
const bus = new EventBus()
// Persistência
const storage = new MediaStorage({ rootPath: config.dataDir })
const channelRepository = new DrizzleChannelRepository({ drizzle: db })
// Use cases (recebem deps por props)
const startRecording = new StartRecordingUseCase({ streamRepository, storage, recorder, streamMetaStorage })
// ... etc
// Wiring: comandos e reações
bus.subscribe(ChannelLiveEvent, async event => {
const result = await startRecording.execute({...})
if (result.isFailure()) console.error('[start-recording]', result.value)
})# Instalar deps
bun install
# Rodar o daemon (bloqueante — Ctrl+C pra parar)
bun apps/daemon/src/main.ts
# Em outro terminal: usar o CLI
bun scrapper ping # verifica que o daemon está vivo
bun scrapper add-channel <username> # cadastra canal pra monitorar
bun scrapper enable-auto-recording <user> # habilita gravação automática
bun scrapper disable-auto-recording <user> # desliga gravação automática
bun scrapper remove-channel <user> # remove canal (bloqueado se estiver gravando)
bun scrapper list-channels # lista canais e o status de cada um
bun scrapper start-record <user> # força gravação (canal precisa estar ao vivo)
bun scrapper stop-record <user> # interrompe gravação em andamento
bun scrapper channel-details <user> # perfil do canal + streams, gravações e downloads de VOD
bun scrapper download-vod <streamId> # baixa o VOD de uma stream já registrada (oficial, com fallback via CDN)
bun scrapper add-harvest-channel <channel> # adiciona canal à lista de harvesting ativo de CDN
bun scrapper remove-harvest-channel <channel> # remove canal da lista de harvesting ativo
bun scrapper list-harvest-channels # lista os canais dessa listabun test # unit — rápido, isolado
bun run test:integration # integration — DB in-memory, IPC real, fake Twitch
bun run test:e2e # smoke E2E — sobe daemon isolado, toca Twitch de verdadecd apps/daemon
bun run db:generate # gera SQL da diferença entre schema.ts e último snapshot
bun run db:migrate # aplica migrations pendentes
bun run db:studio # UI web pra explorar o DB