Skip to content

Commit c59ee8e

Browse files
authored
feat: add sse-counter example (#110)
1 parent 75ca1e1 commit c59ee8e

File tree

13 files changed

+302
-0
lines changed

13 files changed

+302
-0
lines changed

sse-counter/.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-counter/.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-counter/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Server-Sent Events - Counter
2+
3+
This example demonstrates how to use Server-Sent Events to create a simple counter that updates in real-time.
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-counter)
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 starts an interval that emits a new message every second with the current time formatted in English.
16+
17+
Client-side, the `useEventSource` hook from Remix Utils is used to subscribe to the SSE endpoint and display the new date.
18+
19+
## Related Links
20+
21+
- [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
22+
- [Remix Utils](https://github.com/sergiodxa/remix-utils#server-sent-events)
23+
- [Event Emitter](https://nodejs.org/api/events.html#events_class_eventemitter)

sse-counter/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-counter/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-counter/app/root.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { MetaFunction } from "@remix-run/node";
2+
import { json } from "@remix-run/node";
3+
import {
4+
Links,
5+
LiveReload,
6+
Meta,
7+
Outlet,
8+
Scripts,
9+
ScrollRestoration,
10+
useLoaderData,
11+
} from "@remix-run/react";
12+
import { useEventSource } from "remix-utils";
13+
14+
export const meta: MetaFunction = () => ({
15+
charset: "utf-8",
16+
title: "New Remix App",
17+
viewport: "width=device-width,initial-scale=1",
18+
});
19+
20+
export function loader() {
21+
return json({
22+
initialCount: new Date().toLocaleTimeString("en", {
23+
hour: "2-digit",
24+
minute: "2-digit",
25+
second: "2-digit",
26+
}),
27+
});
28+
}
29+
30+
export default function App() {
31+
const { initialCount } = useLoaderData<typeof loader>();
32+
const count = useEventSource("/sse/counter") ?? initialCount;
33+
return (
34+
<html lang="en">
35+
<head>
36+
<Meta />
37+
<Links />
38+
</head>
39+
<body>
40+
<h1>
41+
The server time is <time>{count}</time>
42+
</h1>
43+
<Outlet />
44+
<ScrollRestoration />
45+
<Scripts />
46+
<LiveReload />
47+
</body>
48+
</html>
49+
);
50+
}

sse-counter/app/routes/sse.counter.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { LoaderArgs } from "@remix-run/node";
2+
import { eventStream } from "remix-utils";
3+
4+
export function loader({ request }: LoaderArgs) {
5+
return eventStream(request.signal, function setup(send) {
6+
const interval = setInterval(() => {
7+
send({
8+
data: new Date().toLocaleTimeString("en", {
9+
hour: "2-digit",
10+
minute: "2-digit",
11+
second: "2-digit",
12+
}),
13+
});
14+
}, 1000);
15+
16+
return function cleanup() {
17+
clearInterval(interval);
18+
};
19+
});
20+
}

sse-counter/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+
}

sse-counter/public/favicon.ico

16.6 KB
Binary file not shown.

sse-counter/remix.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('@remix-run/dev').AppConfig} */
2+
module.exports = {
3+
ignoredRouteFiles: ["**/.*"],
4+
// appDirectory: "app",
5+
// assetsBuildDirectory: "public/build",
6+
// serverBuildPath: "build/index.js",
7+
// publicPath: "/build/",
8+
};

0 commit comments

Comments
 (0)