Server Sent Events support in Remix #2622
Replies: 10 comments 20 replies
-
This looks awesome!
I wonder if the browser gives us an indication that this is an |
Beta Was this translation helpful? Give feedback.
-
First of all, I did not know about Server Sent Events and presumed that Websockets were just the only way to solve this... so, thanks for "using the platform" and teaching me something new! For UDisc, I think this would perfect for our most common use-case, displaying real-time scores. For example, https://udisclive.com follows the progress of a tournament by listening for scorecard changes. There are really only two scenarios:
I'd expect to use Mongo ChangeStreams for this as our first implementation, since we already have these changes flowing through Mongo. Overall, I think this is a perfect fit and wanted to put this use-case out there to help validate the proposal. One question/concern:How would I ensure that I don't miss any updates between the data loader point-in-time and the subscription starting for new events? While it's probably not likely, I always have to think through these edge cases. I think I would approach this by returning the most recent scorecard |
Beta Was this translation helpful? Give feedback.
-
when learning about SSE, i stumbled across this article https://dev.to/miketalbot/server-sent-events-are-still-not-production-ready-after-a-decade-a-lesson-for-me-a-warning-for-you-2gie. what are your thoughts about this ? |
Beta Was this translation helpful? Give feedback.
-
Since the latest version has support for proper web streams, I think this discussion is moot at this point. |
Beta Was this translation helpful? Give feedback.
-
I was able to get SSE working in a loader function with the following approach. This was implemented in a resource route's loader but it could possibly be implemented in a regular route's loader if it respected hypermedia controls, responding only to a specific value in the export const loader: LoaderFunction = async ({ request }) => {
const body = new ReadableStream({
async start(c) {
c.enqueue(": sending messages\n\n");
const subscription = someObservable.subscribe(
(item) => {
c.enqueue("event: item\n");
c.enqueue(`data: ${JSON.stringify(item)}\n\n`);
},
(error) => {
c.error(error);
}
);
request.signal.onabort = () => {
subscription.unsubscribe();
c.close();
};
},
});
const headers = new Headers();
headers.set("Cache-Control", "no-store, no-transform");
headers.set("Content-Type", "text/event-stream");
return new Response(body, { headers, status: 200 });
}; |
Beta Was this translation helpful? Give feedback.
-
Now that we've got SSE support, it's a bit cumbersome to use, in my limited experience it looks like this: Set up some pub/sub on the server import { EventEmitter } from "events";
export let emitter = new EventEmitter(); Set up an event stream with cleanup and queues and stuff that subscribes to it and streams the events when new stuff comes through: import { emitter } from "../some-emitter.server";
type InitFunction = (send: SendFunction) => CleanupFunction;
type SendFunction = (event: string, data: string) => void;
type CleanupFunction = () => void;
export function eventStream(request: Request, init: InitFunction) {
let stream = new ReadableStream({
start(controller) {
let encoder = new TextEncoder();
let send = (event: string, data: string) => {
controller.enqueue(encoder.encode(`event: ${event}\n`));
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
};
let cleanup = init(send);
let closed = false;
let close = () => {
if (closed) return;
cleanup();
closed = true;
request.signal.removeEventListener("abort", close);
controller.close();
};
request.signal.addEventListener("abort", close);
if (request.signal.aborted) {
close();
return;
}
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
} Return the event stream from a loader in a resource route: import { eventStream } from "./event-stream";
export let loader: LoaderFunction = ({ request }) => {
return eventStream(request, send => {
emitter.addListener("messageReceived", handleChatMessage);
function handleChatMessage(chatMessage: string) {
send("message", chatMessage);
}
return () => {
emitter.removeListener("messageReceived", handleChatMessage);
};
});
}; Push into the event emitter in actions: import { emitter } from "./some-emitter.server";
export let action: ActionFunction = async ({ request }) => {
let formData = await request.formData();
emitter.emit("messageReceived", formData.get("something");
return { ok: true };
}; And finally, set up an EventSource in the browser function useEventSource(href: string) {
let [data, setData] = useState("");
useEffect(() => {
let eventSource = new EventSource(href);
eventSource.addEventListener("message", handler);
function handler(event: MessageEvent) {
setData(event.data || "unknown");
}
return () => {
eventSource.removeEventListener("message", handler);
};
}, []);
return data;
} I'd love to see some community helpers around this stuff for some happy-path SSE workflows, particularly the My code up there is stuff I cobbled together looking at @jacob-ebey's demos and mdn docs, but I literally have zero experience with SSE otherwise. I'd love to see a community effort around a package that adds a little abstraction over all of these APIs to make SSE in a Remix a breeze 🙏 |
Beta Was this translation helpful? Give feedback.
-
I created a simple library for SSE that implements most of the patterns discussed in this thread. https://github.com/dan-cooke/remix-sse There is plenty of room for improvement so PR's are welcome! I will be using this library in production so no doubt I will be adding features over time and mantaining it. |
Beta Was this translation helpful? Give feedback.
-
I have also put together a small library - In import { SseResponse } from "@wehriam/remix-sse";
export let loader = ({ request }) => {
const response = new SseResponse(request);
let count = 0;
const interval = setInterval(() => {
response.send("counter", { count });
count += 1;
}, 1000);
function handleAbort() {
clearInterval(interval);
}
response.signal.addEventListener("abort", handleAbort);
return response;
}; In import { useSse } from "@wehriam/remix-sse";
export default function Index() {
const data = useSse<{ count: number }>("counter", "/counter");
return (
<div>
<h1>SSE Counter Example</h1>
<h2>Count: {data ? data.count : "Unknown"}</h2>
</div>
);
} |
Beta Was this translation helpful? Give feedback.
-
For everyone interested and unaware of the following; two utilities for server-sent events has been added to https://github.com/sergiodxa/remix-utils#server-sent-events |
Beta Was this translation helpful? Give feedback.
-
Has anyone successfully implemented Server-Sent Events (SSE) using Shopify Hydrogen and hosted on Shopify Oxygen? I'm encountering a 'promise hanging' error during implementation. Any insights or best practices to resolve this issue and potential challenges would be greatly appreciated. |
Beta Was this translation helpful? Give feedback.
-
One of the areas that Remix is lacking currently is in support for real-time data from the server.
It's common these days to implement real-time features on top of Web Sockets. They're a robust, if somewhat technically complicated solution.
I believe a full Web Socket abstraction in Remix is out-of-scope (for now), but there's an alternative method that I think fits Remix's philosophy quite nicely:
Server Sent Events
If you're already familiar with what Server Sent Events ("SSE") are, please skip ahead to the next section.
In a nutshell, SSE is a way of informing the browser that an event happened on the server using a long-running request with a specific content type:
text/event-stream
. They're kind of like "formalized long-polling", but unlike regular long polling the browser will not time these requests out if they take too long.SSE responses from the server are standard HTTP responses:
In the browser, the
EventSource
API can be used to subscribe to these events:SSE in Remix
I think SSE fits a niche in Remix for simpler real-time use-cases. For example: a status update from a back-end CI/CD process. Things that require notification from the server, but don't need the development overhead of a Web Socket integration.
I propose that Remix supports a
serverEvents
export from Route modules. The type for this export would look like this:A few things to unpack there:
First off, the
ServerEventsFunction
export receives the sameargs
parameter thatloader
andaction
do. However, it also receives a secondsend
parameter that is used to send events to the client.The
serverEvents
handler can also respond to the connection being closed when the client no longer needs it (eg: a component consuming these events unmounts, or the tab is closed, etc).Much like
useEffect
, the handler can return a cleanup function. For example, say you wanted to hook up to a Redis Pub/Sub channel:The cleanup function can be synchronous or asynchronous.
Another way,of sending events would be to return an AsyncIterable. The most straightforward method of doing that would be to use an async generator function:
When returning an AsyncIterable, you can't also return a cleanup function, so instead we rely on the
AbortSignal
that's attached to the request to tell when to clean up.The various methods of sending events can be combined, as well:
Consuming the
serverEvents
export.As this is a Route module export, we need some way of distinguishing a request for SSE from a "UI" request (like navigating to the route in your browser) or a "data" request (loaders/actions). We can use the "magic" querystring key
_serverevents
much in the same way that loader/action requests use_data
to tell Remix that it shouldn't server render the UI.On the client, a
useServerEvents
(or possiblyuseServerEventsData
) hook will be available in@remix-run/react
.In its simplest form, it will provide the "last event received" as React state, and the component will re-render whenever a new event arrives:
The hook will default to using the current route's
serverEvents
as the source, and listen for the defaultmessage
event.However, you can customize the behavior by passing an options object:
In either case, the raw event data will be parsed using
JSON.parse
before handling.Under the hood, this hook manages instances of
EventSource
and subscribes/unsubscribes handlers for consuming clients.Some notes on behavior:
JSON.stringify
-ed when sent, andJSON.parse
-ed when received.EventSource
instances are unique based onsource + withCredentials
. This means that you can use multipleuseServerEvent
hooks for the samesource
, and at most twoEventSource
instances will be created (they are reference counted by source).This will help to avoid hitting the rather severe limits that SSE have around multiple connections:
(For more information, see the warning here.)
Implementation Status
I have the above actually functioning, and I will be creating a PR for further implementation discussion. Here's the sample videos I posted in Discord to demonstrate my POC (ignore the names, they're old):
Screen_Recording_2022-03-29_at_1.45.38_AM.mov
Screen_Recording_2022-03-29_at_9.48.44_PM.mov
Currently there's some patterns I've had to use that will need more robust solutions than the.. er... "hacky" methods I used.
I'm curious to know what the Remix team and community at large think of this, and whether or not it would be a valuable addition to the framework.
Thanks for reading!
Beta Was this translation helpful? Give feedback.
All reactions