Skip to content

Commit 75ca1e1

Browse files
authored
feat: add sse-chat example (#111)
1 parent 681644b commit 75ca1e1

15 files changed

+368
-0
lines changed

sse-chat/.eslintrc.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** @type {import('eslint').Linter.Config} */
2+
module.exports = {
3+
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4+
};

sse-chat/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
/public/build
6+
.env

sse-chat/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Server-Sent Events - Simple chat app
2+
3+
This example demonstrates how to use Server-Sent Events to create a simple chat app without persistant storage.
4+
5+
## Preview
6+
7+
Open this example on [CodeSandbox](https://codesandbox.com):
8+
9+
[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/examples/tree/main/sse-chat)
10+
11+
## Example
12+
13+
The example uses the `eventStream` response helper from Remix Utils to implement a SSE endpoint.
14+
15+
In that endpoint the server subscribe to an EventEmitter to get new messages and broadcast them to subscribers.
16+
17+
Client-side, the `useEventSource` hook from Remix Utils is used to subscribe to the SSE endpoint and display new messages as they arrive.
18+
19+
All paired with a Remix form to send new messages to an action which are then emitted to the EventEmitter.
20+
21+
## Related Links
22+
23+
- [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
24+
- [Remix Utils](https://github.com/sergiodxa/remix-utils#server-sent-events)
25+
- [Event Emitter](https://nodejs.org/api/events.html#events_class_eventemitter)
26+
- [Remix Form](https://remix.run/docs/en/v1/components/form)
27+
- [Remix action](https://remix.run/docs/en/v1/route/action)

sse-chat/app/entry.client.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { RemixBrowser } from "@remix-run/react";
2+
import { startTransition, StrictMode } from "react";
3+
import { hydrateRoot } from "react-dom/client";
4+
5+
const hydrate = () =>
6+
startTransition(() => {
7+
hydrateRoot(
8+
document,
9+
<StrictMode>
10+
<RemixBrowser />
11+
</StrictMode>
12+
);
13+
});
14+
15+
if (window.requestIdleCallback) {
16+
window.requestIdleCallback(hydrate);
17+
} else {
18+
// Safari doesn't support requestIdleCallback
19+
// https://caniuse.com/requestidlecallback
20+
window.setTimeout(hydrate, 1);
21+
}

sse-chat/app/entry.server.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { PassThrough } from "stream";
2+
3+
import type { EntryContext } from "@remix-run/node";
4+
import { Response } from "@remix-run/node";
5+
import { RemixServer } from "@remix-run/react";
6+
import isbot from "isbot";
7+
import { renderToPipeableStream } from "react-dom/server";
8+
9+
const ABORT_DELAY = 5000;
10+
11+
const handleRequest = (
12+
request: Request,
13+
responseStatusCode: number,
14+
responseHeaders: Headers,
15+
remixContext: EntryContext
16+
) =>
17+
isbot(request.headers.get("user-agent"))
18+
? handleBotRequest(
19+
request,
20+
responseStatusCode,
21+
responseHeaders,
22+
remixContext
23+
)
24+
: handleBrowserRequest(
25+
request,
26+
responseStatusCode,
27+
responseHeaders,
28+
remixContext
29+
);
30+
export default handleRequest;
31+
32+
const handleBotRequest = (
33+
request: Request,
34+
responseStatusCode: number,
35+
responseHeaders: Headers,
36+
remixContext: EntryContext
37+
) =>
38+
new Promise((resolve, reject) => {
39+
let didError = false;
40+
41+
const { pipe, abort } = renderToPipeableStream(
42+
<RemixServer context={remixContext} url={request.url} />,
43+
{
44+
onAllReady: () => {
45+
const body = new PassThrough();
46+
47+
responseHeaders.set("Content-Type", "text/html");
48+
49+
resolve(
50+
new Response(body, {
51+
headers: responseHeaders,
52+
status: didError ? 500 : responseStatusCode,
53+
})
54+
);
55+
56+
pipe(body);
57+
},
58+
onShellError: (error: unknown) => {
59+
reject(error);
60+
},
61+
onError: (error: unknown) => {
62+
didError = true;
63+
64+
console.error(error);
65+
},
66+
}
67+
);
68+
69+
setTimeout(abort, ABORT_DELAY);
70+
});
71+
72+
const handleBrowserRequest = (
73+
request: Request,
74+
responseStatusCode: number,
75+
responseHeaders: Headers,
76+
remixContext: EntryContext
77+
) =>
78+
new Promise((resolve, reject) => {
79+
let didError = false;
80+
81+
const { pipe, abort } = renderToPipeableStream(
82+
<RemixServer context={remixContext} url={request.url} />,
83+
{
84+
onShellReady: () => {
85+
const body = new PassThrough();
86+
87+
responseHeaders.set("Content-Type", "text/html");
88+
89+
resolve(
90+
new Response(body, {
91+
headers: responseHeaders,
92+
status: didError ? 500 : responseStatusCode,
93+
})
94+
);
95+
96+
pipe(body);
97+
},
98+
onShellError: (error: unknown) => {
99+
reject(error);
100+
},
101+
onError: (error: unknown) => {
102+
didError = true;
103+
104+
console.error(error);
105+
},
106+
}
107+
);
108+
109+
setTimeout(abort, ABORT_DELAY);
110+
});

sse-chat/app/root.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { MetaFunction } from "@remix-run/node";
2+
import {
3+
Links,
4+
LiveReload,
5+
Meta,
6+
Outlet,
7+
Scripts,
8+
ScrollRestoration,
9+
} from "@remix-run/react";
10+
11+
export const meta: MetaFunction = () => ({
12+
charset: "utf-8",
13+
title: "New Remix App",
14+
viewport: "width=device-width,initial-scale=1",
15+
});
16+
export default function App() {
17+
return (
18+
<html lang="en">
19+
<head>
20+
<Meta />
21+
<Links />
22+
</head>
23+
<body>
24+
<Outlet />
25+
<ScrollRestoration />
26+
<Scripts />
27+
<LiveReload />
28+
</body>
29+
</html>
30+
);
31+
}

sse-chat/app/routes/index.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { LoaderArgs } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
3+
import { Form, useLocation } from "@remix-run/react";
4+
import { useEffect, useRef, useState } from "react";
5+
import { useEventSource } from "remix-utils";
6+
7+
import { emitter } from "~/services/emitter";
8+
9+
export async function action({ request }: LoaderArgs) {
10+
const formData = await request.formData();
11+
const message = formData.get("message");
12+
emitter.emit("message", message);
13+
return json({ message });
14+
}
15+
16+
export default function Component() {
17+
const $form = useRef<HTMLFormElement>(null);
18+
const { key } = useLocation();
19+
useEffect(
20+
function clearFormOnSubmit() {
21+
$form.current?.reset();
22+
},
23+
[key]
24+
);
25+
26+
const [messages, setMessages] = useState<string[]>([]);
27+
const lastMessage = useEventSource("/sse/chat");
28+
useEffect(
29+
function saveMessage() {
30+
setMessages((current) => {
31+
if (typeof lastMessage === "string") return current.concat(lastMessage);
32+
return current;
33+
});
34+
},
35+
[lastMessage]
36+
);
37+
38+
return (
39+
<>
40+
<Form ref={$form} method="post">
41+
<label>Message</label>
42+
<input type="text" name="message" />
43+
<button>Send</button>
44+
</Form>
45+
46+
<ul>
47+
{messages.map((message) => (
48+
<li key={message}>{message}</li>
49+
))}
50+
</ul>
51+
</>
52+
);
53+
}

sse-chat/app/routes/sse.chat.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { LoaderArgs } from "@remix-run/node";
2+
import { eventStream } from "remix-utils";
3+
4+
import { emitter } from "~/services/emitter";
5+
6+
export function loader({ request }: LoaderArgs) {
7+
return eventStream(request.signal, function setup(send) {
8+
function listener(value: string) {
9+
send({ data: value });
10+
}
11+
12+
emitter.on("message", listener);
13+
14+
return function cleanup() {
15+
emitter.off("message", listener);
16+
};
17+
});
18+
}

sse-chat/app/services/emitter.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* This file is used to create a global EventEmitter instance.
3+
* This is used to emit events from the action to the SSE route
4+
* so that the SSE route can send the event to the client.
5+
*
6+
* This example uses the EventEmitter class from the events built-in module.
7+
* You can use any other event emitter you want. For example, you can use
8+
* Redis or any PubSub technology to implement the same.
9+
*
10+
* In a real app, you would probably want to use one of those because your app
11+
* will probably be running on multiple servers.
12+
*/
13+
import { EventEmitter } from "events";
14+
15+
let emitter: EventEmitter;
16+
17+
declare global {
18+
var __emitter: EventEmitter | undefined;
19+
}
20+
21+
if (process.env.NODE_ENV === "production") {
22+
emitter = new EventEmitter();
23+
} else {
24+
if (!global.__emitter) {
25+
global.__emitter = new EventEmitter();
26+
}
27+
emitter = global.__emitter;
28+
}
29+
30+
export { emitter };

sse-chat/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"private": true,
3+
"sideEffects": false,
4+
"scripts": {
5+
"build": "remix build",
6+
"dev": "remix dev",
7+
"start": "remix-serve build"
8+
},
9+
"dependencies": {
10+
"@remix-run/node": "*",
11+
"@remix-run/react": "*",
12+
"@remix-run/serve": "*",
13+
"isbot": "^3.6.5",
14+
"react": "^18.2.0",
15+
"react-dom": "^18.2.0",
16+
"remix-utils": "^5.1.0"
17+
},
18+
"devDependencies": {
19+
"@remix-run/dev": "*",
20+
"@remix-run/eslint-config": "*",
21+
"@types/react": "^18.0.25",
22+
"@types/react-dom": "^18.0.8",
23+
"eslint": "^8.27.0",
24+
"typescript": "^4.8.4"
25+
},
26+
"engines": {
27+
"node": ">=14"
28+
}
29+
}

0 commit comments

Comments
 (0)