Stream server
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.
pnpm dev, on a Node / Bun / Deno container, on a long-lived VM, on Fly / Railway / Coolify-style instances.What boots up
When you opt in, evlog calls startStreamServer() and:
- Opens a
node:httpserver bound to127.0.0.1on an OS-assigned ephemeral port. - Subscribes the SSE connections to the default in-process stream — every wide event flows through.
- Writes the URL to
<cwd>/.evlog/stream.urlso external tools can discover the port. - Prints a banner at startup:
[evlog] Stream → http://127.0.0.1:51203
- Cleans up the URL file and closes the server on
SIGINT,SIGTERM, and process exit.
Per framework
Nuxt
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)
import { defineStreamedInstrumentation } from 'evlog/next/stream'
export const { register, onRequestError } = defineStreamedInstrumentation({
service: 'my-app',
stream: true,
})
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,
})
| Mode | Behavior |
|---|---|
token set | Authorization: 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-local | 403. |
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
| Path | Purpose |
|---|---|
GET / | The SSE stream itself. Accepts ?since=<iso> to replay buffered events. |
GET /info | JSON { 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}}
| Type | When |
|---|---|
hello | First frame — server version + stream config |
replay | Each buffered event flushed when the client passed ?since= |
event | Each new event drained after the connection opened |
ping | Heartbeat 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:
.evlog/stream.url— read directly from the project directory. Cleaned up at process exit.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
- 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.