Skip to content

Commit

Permalink
Extended API to support WebSocket (#24)
Browse files Browse the repository at this point in the history
* websocket support for node

* handle upgrade listener patch in the code instead of node module patch

* add socketio example

* add websocket example for cloudflare

* update doc
  • Loading branch information
rphlmr authored Dec 8, 2024
1 parent 7f57050 commit aeebbb7
Show file tree
Hide file tree
Showing 294 changed files with 8,447 additions and 7,223 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,6 @@ dist
.pnp.*
/dist
.history
.react-router
.react-router
/build
.wrangler
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,9 @@
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"deno.enable": false
"deno.enable": false,
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml"
},
"typescript.tsdk": "node_modules/typescript/lib"
}
5 changes: 4 additions & 1 deletion Dockerfile.bun
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ ENV NODE_ENV=production
# run the app
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "run", "examples/react-router-bun/build/server/index.js" ]
EXPOSE 3000/udp
ENV HOST=0.0.0.0
WORKDIR /usr/src/app/examples/bun/websocket
ENTRYPOINT [ "bun", "run", "build/server/index.js" ]
134 changes: 123 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Ok, by default it works, but you may want to customize the server and use some m
#### Node
> [!TIP]
> Check this [example](./examples/react-router) to see how to use it.
> Check this [example](./examples/node/simple/) to see how to use it.
```ts
// vite.config.ts
Expand All @@ -121,7 +121,7 @@ export default defineConfig({

#### Bun
> [!TIP]
> Check this [example](./examples/react-router-bun) to see how to use it.
> Check this [example](./examples/bun/simple/) to see how to use it.
```ts
// vite.config.ts
Expand All @@ -141,7 +141,7 @@ export default defineConfig({

#### Cloudflare Workers
> [!TIP]
> Check this [example](./examples/react-router-cloudflare) to see how to use it.
> Check this [example](./examples/cloudflare/simple/) to see how to use it.
> [!IMPORTANT]
> You need to add the `cloudflareDevProxy` plugin to use the Cloudflare Workers runtime on dev.
Expand Down Expand Up @@ -313,6 +313,12 @@ type ReactRouterHonoServerPluginOptions = {
##### All adapters
```ts
export type HonoServerOptions<E extends Env = BlankEnv> = {
/**
* Hono app to use
*
* {@link Hono}
*/
app?: Hono<E>;
/**
* Enable the default logger
*
Expand Down Expand Up @@ -360,12 +366,6 @@ export type HonoServerOptions<E extends Env = BlankEnv> = {
* Defaults log the port
*/
listeningListener?: (info: AddressInfo) => void;
/**
* Hono constructor options
*
* {@link HonoOptions}
*/
honoOptions?: HonoOptions<E>;
};
```
Expand Down Expand Up @@ -434,6 +434,14 @@ export interface HonoServerOptions<E extends Env = BlankEnv> extends HonoServerO
* {@link https://hono.dev/docs/getting-started/nodejs#http2}
*/
customNodeServer?: CreateNodeServerOptions;
/**
* Callback executed just after `serve` from `@hono/node-server`
*
* **Only applied to production mode**
*
* For example, you can use this to bind `@hono/node-ws`'s `injectWebSocket`
*/
onServe?: (server: ServerType) => void;
}
```
Expand Down Expand Up @@ -533,6 +541,110 @@ export default await createHonoServer({
});
```
### Using WebSockets
#### Node
This package has a built-in helper to use `@hono/node-ws`
> [!TIP]
> Check this [example](./examples/node/websocket/) to see how to use it.
```ts
import type { WSContext } from "hono/ws";
import { createHonoServer } from "react-router-hono-server/node";
// Store connected clients
const clients = new Set<WSContext>();
export default await createHonoServer({
useWebSocket: true,
// 👆 Unlock this 👇 from @hono/node-ws
configure: (app, { upgradeWebSocket }) => {
app.get(
"/ws",
upgradeWebSocket((c) => ({
// https://hono.dev/helpers/websocket
onOpen(_, ws) {
console.log("New connection ⬆️");
clients.add(ws);
},
onMessage(event, ws) {
console.log("Context", c.req.header("Cookie"));
console.log("Event", event);
console.log(`Message from client: ${event.data}`);
// Broadcast to all clients except sender
clients.forEach((client) => {
if (client.readyState === 1) {
client.send(`${event.data}`);
}
});
},
onClose(_, ws) {
console.log("Connection closed");
clients.delete(ws);
},
}))
);
},
});
```
#### Bun
This package has a built-in helper to use `hono/bun` in prod and `@hono/node-ws` in dev
> [!TIP]
> Check this [example](./examples/bun/websocket/) to see how to use it.
```ts
import { WSContext } from "hono/ws";
import { createHonoServer } from "react-router-hono-server/bun";
// Store connected clients
const clients = new Set<WSContext>();
export default await createHonoServer({
useWebSocket: true,
// 👆 Unlock this 👇 from @hono/node-ws in dev, hono/bun in prod
configure(app, { upgradeWebSocket }) {
app.get(
"/ws",
upgradeWebSocket((c) => ({
// https://hono.dev/helpers/websocket
onOpen(_, ws) {
console.log("New connection 🔥");
clients.add(ws);
},
onMessage(event, ws) {
console.log("Context", c.req.header("Cookie"));
console.log("Event", event);
console.log(`Message from client: ${event.data}`);
// Broadcast to all clients except sender
clients.forEach((client) => {
if (client.readyState === 1) {
client.send(`${event.data}`);
}
});
},
onClose(_, ws) {
console.log("Connection closed");
clients.delete(ws);
},
}))
);
},
});
```
#### Cloudflare Workers
Cloudflare requires a different approach to WebSockets, based on Durable Objects.
> [!TIP]
> Check this [example](./examples/cloudflare/websocket/) to see how to use it.
> [!IMPORTANT]
> For now, HMR is not supported in Cloudflare Workers. Will try to come back to it later.
>
> Work in progress on Cloudflare team: https://github.com/flarelabs-net/vite-plugin-cloudflare
### Migrate from v1
_You should not expect any breaking changes._
Expand Down Expand Up @@ -594,9 +706,9 @@ Many options are gone or have changed.
##### You used `buildEnd` from `remix()` plugin or a custom `buildDirectory` option
You may know that it has been moved to `react-router.config.ts` (see [here](https://reactrouter.com/upgrading/remix#5-add-a-react-router-config) for more information).
If you used this hook for Sentry, check this [example](./examples/react-router-sentry/react-router.config.ts) to see how to migrate.
If you used this hook for Sentry, check this [example](./examples/node/with-sentry/react-router.config.ts) to see how to migrate.
If you used a custom `buildDirectory` option, check this [example](./examples/react-router-custom-build/react-router.config.ts) to see how to migrate.
If you used a custom `buildDirectory` option, check this [example](./examples/node/custom-build/react-router.config.ts) to see how to migrate.
#### Update your package.json scripts
```json
Expand Down
6 changes: 5 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"ignore": ["dist", "build"]
},
"vcs": {
"enabled": true,
"clientKind": "git",
Expand All @@ -22,7 +25,8 @@
"rules": {
"recommended": true,
"suspicious": {
"recommended": true
"recommended": true,
"noExplicitAny": "off"
},
"style": {
"recommended": true,
Expand Down
Binary file modified bun.lockb
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
4 changes: 4 additions & 0 deletions examples/bun/simple/app/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// generated by react-router-hono-server/dev
import { createHonoServer } from "react-router-hono-server/bun";

export default await createHonoServer();
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "react-router-bun-example",
"name": "bun-simple",
"private": true,
"sideEffects": true,
"type": "module",
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
137 changes: 137 additions & 0 deletions examples/bun/websocket/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import type { Route } from "./+types/_index";

export function loader() {
return {
isDev: process.env.NODE_ENV === "development",
};
}

let isHydrating = true;

export default function Index({ loaderData }: Route.ComponentProps) {
const [isHydrated, setIsHydrated] = useState(!isHydrating);

useEffect(() => {
isHydrating = false;
setIsHydrated(true);
}, []);

if (isHydrated) {
return <Client isDev={loaderData.isDev} />;
} else {
return <div>Loading...</div>;
}
}

function Client({ isDev }: { isDev: boolean }) {
const [messageHistory, setMessageHistory] = useState<MessageEvent<any>[]>([]);
const [message, setMessage] = useState("");

// Adapt the port based on some env.
const { sendMessage, lastMessage, readyState } = useWebSocket(`ws://localhost:${isDev ? 5173 : 3000}/ws`);

useEffect(() => {
if (lastMessage !== null) {
setMessageHistory((prev) => prev.concat(lastMessage));
}
}, [lastMessage]);

const handleClickSendMessage = useCallback(() => {
if (message.trim()) {
sendMessage(message);
setMessage("");
}
}, [message, sendMessage]);

const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
}[readyState];

return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow-sm rounded-lg p-6">
{/* Connection Status */}
<div className="mb-6">
<div className="flex items-center space-x-2">
<div
className={`h-3 w-3 rounded-full ${
readyState === ReadyState.OPEN
? "bg-green-500"
: readyState === ReadyState.CONNECTING
? "bg-yellow-500 animate-pulse"
: "bg-red-500"
}`}
/>
<span className="text-sm text-gray-600">
Status: <span className="font-medium">{connectionStatus}</span>
</span>
<span className="text-sm text-gray-600">
Port: <span className="font-medium">{isDev ? 5173 : 3000}</span>
</span>
</div>
</div>

{/* Send Message Input and Button */}
<div className="mb-8 space-y-4">
<div className="flex space-x-4">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1 min-w-0 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
disabled={readyState !== ReadyState.OPEN}
/>
<button
onClick={handleClickSendMessage}
disabled={readyState !== ReadyState.OPEN || !message.trim()}
className={`inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white
${
readyState === ReadyState.OPEN && message.trim()
? "bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
: "bg-gray-400 cursor-not-allowed"
}`}
>
Send
</button>
</div>
{readyState !== ReadyState.OPEN && (
<p className="text-sm text-red-500">Connect to the WebSocket to send messages</p>
)}
</div>

{/* Last Message */}
{lastMessage && (
<div className="mb-6 p-4 bg-gray-50 rounded-md">
<h3 className="text-sm font-medium text-gray-900 mb-1">Last message:</h3>
<p className="text-sm text-gray-600">{lastMessage.data}</p>
</div>
)}

{/* Message History */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900">Message History</h3>
<div className="border rounded-md divide-y">
{messageHistory.length === 0 ? (
<p className="text-sm text-gray-500 p-4">No messages yet</p>
) : (
messageHistory.map((message, idx) => (
<div key={idx} className="p-4">
<p className="text-sm text-gray-600">{message.data}</p>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
);
}
Loading

0 comments on commit aeebbb7

Please sign in to comment.