Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/yellow-pugs-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"next-ws": minor
---

Add WebSocket adapter support for multi-instance deployments
57 changes: 57 additions & 0 deletions .github/workflows/redis-adapter-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Redis Adapter Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test-redis-adapter:
runs-on: ubuntu-latest

services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10.15.0

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build project
run: pnpm build

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Run Redis adapter tests
run: pnpm test tests/redis-adapter.test.ts
env:
REDIS_URL: redis://localhost:6379
CI: true

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/.report/
retention-days: 7
19 changes: 19 additions & 0 deletions examples/redis-adapter/app/(simple)/api/ws/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { headers } from 'next/headers';

export function GET() {
const headers = new Headers();
headers.set('Connection', 'Upgrade');
headers.set('Upgrade', 'websocket');
return new Response('Upgrade Required', { status: 426, headers });
}

export async function UPGRADE(client: import('ws').WebSocket) {
await headers();

client.send(
JSON.stringify({
author: 'System',
content: `Connected to instance ${process.env.INSTANCE_ID || 'unknown'}`,
}),
);
}
16 changes: 16 additions & 0 deletions examples/redis-adapter/app/(simple)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room';

export default function Page() {
const [messages, sendMessage] = useMessaging(
() => `ws://${window.location.host}/api/ws`,
);

return (
<div style={{ maxWidth: '50vh' }}>
<MessageList messages={messages} />
<MessageSubmit onMessage={sendMessage} />
</div>
);
}
17 changes: 17 additions & 0 deletions examples/redis-adapter/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export default function Layout({ children }: React.PropsWithChildren) {
return (
<html lang="en" style={{ fontFamily: 'sans-serif' }}>
<body
style={{
backgroundColor: 'black',
color: 'white',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{children}
</body>
</html>
);
}
1 change: 1 addition & 0 deletions examples/redis-adapter/global.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;
6 changes: 6 additions & 0 deletions examples/redis-adapter/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
20 changes: 20 additions & 0 deletions examples/redis-adapter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@examples/redis-adapter",
"private": true,
"scripts": {
"dev": "tsx --require=\"./global.js\" server.ts",
"prepare": "next-ws patch"
},
"dependencies": {
"ioredis": "catalog:",
"next": "catalog:",
"next-ws": "workspace:^",
"react": "catalog:",
"react-dom": "catalog:",
"shared": "workspace:^"
},
"devDependencies": {
"@types/react": "catalog:",
"tsx": "^4.20.4"
}
}
63 changes: 63 additions & 0 deletions examples/redis-adapter/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Server } from 'node:http';
import { parse } from 'node:url';
import Redis from 'ioredis';
import next from 'next';
import {
type Adapter,
setAdapter,
setHttpServer,
setWebSocketServer,
} from 'next-ws/server';
import { WebSocketServer } from 'ws';

class RedisAdapter implements Adapter {
private pub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
private sub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

async broadcast(room: string, message: unknown): Promise<void> {
const messageStr =
typeof message === 'string'
? message
: Buffer.isBuffer(message)
? message.toString('utf-8')
: JSON.stringify(message);
await this.pub.publish(room, messageStr);
}

onMessage(room: string, handler: (message: unknown) => void): void {
this.sub.subscribe(room);
this.sub.on('message', (channel: string, msg: string) => {
if (channel === room) handler(msg);
});
}

async close(): Promise<void> {
await Promise.all([this.pub.quit(), this.sub.quit()]);
}
}

const httpServer = new Server();
setHttpServer(httpServer);
const webSocketServer = new WebSocketServer({ noServer: true });
setWebSocketServer(webSocketServer);
setAdapter(new RedisAdapter());

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
const app = next({ dev, hostname, port, customServer: true });
const handle = app.getRequestHandler();

app.prepare().then(() => {
httpServer
.on('request', async (req, res) => {
if (!req.url) return;
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
})
.listen(port, () => {
console.log(
` ▲ Ready on http://${hostname}:${port} (Instance ${process.env.INSTANCE_ID || 'unknown'})`,
);
});
});
56 changes: 56 additions & 0 deletions examples/redis-adapter/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",

"include": ["app/**/*", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next"],

"compileOnSave": true,
"compilerOptions": {
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,

"allowArbitraryExtensions": false,
"allowImportingTsExtensions": false,
"allowJs": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"resolvePackageJsonExports": true,
"resolvePackageJsonImports": true,

"declaration": true,
"declarationMap": true,
"importHelpers": false,
"newLine": "lf",
"noEmit": true,
"noEmitHelpers": true,
"removeComments": false,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,

"experimentalDecorators": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"target": "ES2022",
"useDefineForClassFields": true,
"skipLibCheck": true,

"jsx": "preserve",
"outDir": "./dist",
"paths": { "~/*": ["./src/*"] },

"plugins": [{ "name": "next" }],
"incremental": true
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@types/ws": "^8.18.1",
"chalk": "^5.6.0",
"husky": "^9.1.7",
"ioredis": "^5.8.1",
"next": "catalog:",
"pinst": "^3.0.0",
"react": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ export default defineConfig({
port: 3002,
reuseExistingServer: !process.env.CI,
},
// Note: redis-adapter tests spawn their own instances
],
});
Loading