Skip to content
Open
Show file tree
Hide file tree
Changes from all 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'})`,
);
});
});
55 changes: 55 additions & 0 deletions examples/redis-adapter/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"$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",

"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