Horizon

Stream Server-Sent Events through a tunnel

Stream Server-Sent Events from a local Next.js route handler through a Horizon tunnel, and watch them arrive with curl -N.

Stream Server-Sent Events (SSE) from your laptop. Horizon streams responses through unbuffered, so each event arrives as you send it.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A Next.js app using the App Router
  • Optional: a reserved subdomain for -s. Reserve one on the Subdomains page.

Add a streaming route

An SSE response uses the text/event-stream content type. Each event is a block of text that ends with a blank line. A data: line carries the payload.

This route handler sends one event per second, ten times, then closes the stream.

app/api/events/route.ts
export const dynamic = "force-dynamic";

const encoder = new TextEncoder();

export async function GET() {
  let count = 0;
  let timer: ReturnType<typeof setInterval>;

  const stream = new ReadableStream({
    start(controller) {
      timer = setInterval(() => {
        count += 1;
        controller.enqueue(encoder.encode(`data: tick ${count}\n\n`));
        if (count === 10) {
          clearInterval(timer);
          controller.close();
        }
      }, 1000);
    },
    cancel() {
      clearInterval(timer);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
    },
  });
}

Start the app on port 3000.

npm run dev

Start a tunnel

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Drop -s my-app for a free random subdomain. It changes every run. Reserved subdomains are a paid feature, see Pricing.

Watch the stream with curl

Pass -N so curl doesn't buffer its output. curl sends a non-browser user agent, so it skips the Before you continue page.

curl -N https://my-app.hrzn.run/api/events

Read it in a browser

In a browser, use EventSource. A browser that opens the tunnel for the first time sees the Before you continue page, so open the site once and select Continue to site first.

src/events.ts
const source = new EventSource("https://my-app.hrzn.run/api/events");

source.onmessage = (event) => console.log(event.data);
source.onerror = () => source.close();

If the page that runs this code is on another origin, your route must send CORS headers. See Share a local API.

Check it works

curl prints one line per second, not all ten at the end:

Output
data: tick 1

data: tick 2

data: tick 3

Your Horizon terminal prints one line for the request:

Output
  GET     200  /api/events

Troubleshooting

Events arrive all at once

  • Check that you passed -N to curl.
  • Check that your route returns the stream without awaiting the whole response first.
  • Check that the route is dynamic. export const dynamic = "force-dynamic" keeps Next.js from caching it.

The browser shows an error and reconnects

EventSource reconnects when the stream closes. The example closes after ten events, so the browser reconnects. Close the source in onerror, as the example does.

Visitors see a Before you continue page

A browser sees it once every 7 days per IP address. They select Continue to site. Requests with an x-hrzn-skip-warning header or a non-browser user agent skip it.

The URL changed after a restart

You started the tunnel without -s. Restart with -s my-app. -s needs a subdomain you reserved, see Pricing.

Next steps

On this page