Observers

Stream server

A local HTTP mini-server on its own port that exposes the in-process stream over Server-Sent Events. Strict opt-in, framework-agnostic, no app route to wire.

evlog ships a tiny HTTP server that exposes the in-process stream over Server-Sent Events. It runs in the same Node process as your app, on its own ephemeral port — your API surface is untouched, and any consumer (browser tab, CLI, Tauri/Electron devtool) can subscribe.

Local development and long-lived self-hosted servers only.The server lives in-process. On serverless platforms (Vercel Functions, Cloudflare Workers, AWS Lambda…), each invocation is isolated, so a subscriber on one isolate would never see events emitted from another. Use a real broker (Redis Streams, NATS, Pub/Sub…) for cross-instance fan-out.It works perfectly in pnpm dev, on a Node / Bun / Deno container, on a long-lived VM, on Fly / Railway / Coolify-style instances.
Strict opt-in. Nothing starts unless you set the option explicitly. There is no auto-enable in dev — the server only boots when you ask for it.

What boots up

When you opt in, evlog calls startStreamServer() and:

  1. Opens a node:http server bound to 127.0.0.1 on an OS-assigned ephemeral port.
  2. Subscribes the SSE connections to the default in-process stream — every wide event flows through.
  3. Writes the URL to <cwd>/.evlog/stream.url so external tools can discover the port.
  4. Prints a banner at startup:
  [evlog] Stream → http://127.0.0.1:51203
  1. Cleans up the URL file and closes the server on SIGINT, SIGTERM, and process exit.

Per framework

Nuxt

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
  evlog: {
    stream: true,
  },
})

That's it — pnpm dev boots the server and prints the URL. Pass an options object instead of true for full control:

evlog: {
  stream: { port: 4317, token: process.env.EVLOG_STREAM_TOKEN },
}

The Nuxt module also registers a tiny /api/_evlog/stream-info route that reads .evlog/stream.url and returns the URL — useful when the consumer is a page on the same Nuxt app and needs to discover the mini-server's ephemeral port.

Next.js (instrumentation.ts)

lib/evlog.ts
import { defineStreamedInstrumentation } from 'evlog/next/stream'

export const { register, onRequestError } = defineStreamedInstrumentation({
  service: 'my-app',
  stream: true,
})
instrumentation.ts
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() =>
  import('./lib/evlog')
)

The stream server's drain is composed with any user-provided drain so events keep flowing to your other adapters too.

Standalone Node / Bun / Deno script

import { startStreamServer } from 'evlog/stream'
import { initLogger } from 'evlog'

const server = await startStreamServer()
initLogger({ drain: server.drain })

// ... your script runs, devtools can subscribe ...

Hono / Express / Fastify / Elysia / NestJS / SvelteKit

These integrations work as documented in their respective Frameworks pages — no extra setup is required to use them with the stream server. The server is independent of the framework middleware: import startStreamServer() once at boot and pass server.drain wherever you compose your evlog drain.

import { startStreamServer } from 'evlog/stream'

const server = await startStreamServer()
// then plug `server.drain` into your evlog drain composer

API

import { startStreamServer, type StreamServer, type StreamServerOptions } from 'evlog/stream'

const server: StreamServer = await startStreamServer({
  port: 0,                  // 0 = OS picks ephemeral port (default)
  host: '127.0.0.1',        // default — local-only, never exposed to LAN
  token: 'optional-bearer', // default: none (origin check used instead)
  heartbeatMs: 15_000,      // default
  buffer: 500,              // default ring buffer size
  banner: true,             // default — prints `[evlog] Stream → ...`
  urlFileDir: '.evlog',     // default — false to disable .evlog/stream.url
})

server.url   // → 'http://127.0.0.1:51203'
server.port  // → 51203
server.drain // DrainFn — pass to nitroApp.hooks.hook('evlog:drain', drain) or initLogger({ drain })
server.stream // StreamDrain (the underlying in-process pub/sub)
await server.close() // stop, remove .evlog/stream.url, unsubscribe clients

startStreamServer() is idempotent — calling it again returns the same instance until close() is called.

Security

The server binds to 127.0.0.1 by default and is unreachable from the LAN. For any non-local exposure (different host, reverse-proxy, port-forward), add a bearer token:

evlog: {
  stream: {
    token: process.env.EVLOG_STREAM_TOKEN,
  },
}

Or programmatically:

const server = await startStreamServer({
  token: process.env.EVLOG_STREAM_TOKEN,
})
ModeBehavior
token setAuthorization: Bearer <token> is required. 401 otherwise.
token unset, request has no Origin (curl, Node fetch)Allowed.
token unset, request Origin is local (localhost, 127.0.0.1, ::1)Allowed.
token unset, request Origin is non-local403.

You can also override host, but think twice — exposing the server beyond 127.0.0.1 without a token is unsafe. Wide events often carry user data your other adapters would normally redact.

Endpoints

PathPurpose
GET /The SSE stream itself. Accepts ?since=<iso> to replay buffered events.
GET /infoJSON { evlogVersion, bufferSize, heartbeatMs } — server discovery.
OPTIONS *CORS preflight (the server allows * because it binds to localhost).

Wire format

Every SSE data: line is a versioned envelope:

data: {"evlog":"1","type":"hello","data":{"evlogVersion":"2.16.0","bufferSize":500,"heartbeatMs":15000}}

data: {"evlog":"1","type":"replay","data":{...wide event...}}

data: {"evlog":"1","type":"event","data":{...wide event...}}

event: ping
data: {"evlog":"1","type":"ping","data":{"t":1730000000000}}
TypeWhen
helloFirst frame — server version + stream config
replayEach buffered event flushed when the client passed ?since=
eventEach new event drained after the connection opened
pingHeartbeat every heartbeatMs (default 15s), sent with event: ping

The evlog: "1" discriminant is the protocol version — incompatible changes will bump it.

Discovery

External tools (a Tauri devtool, a CLI watcher) can find the running server in two ways:

  1. .evlog/stream.url — read directly from the project directory. Cleaned up at process exit.
  2. GET /api/_evlog/stream-info (Nuxt only) — returns { url }, reads from the file.
# CLI consumer
URL=$(cat .evlog/stream.url) && curl -N "$URL"

Going further

  • Consumer recipes — copy-paste examples for browser, curl + jq, Node fetch, replay-then-live, aggregation.
  • Stream API — the in-process primitive the server is built on.