diff --git a/.changeset/healthy-horses-sin.md b/.changeset/healthy-horses-sin.md new file mode 100644 index 0000000..bd4d8c1 --- /dev/null +++ b/.changeset/healthy-horses-sin.md @@ -0,0 +1,5 @@ +--- +"next-ws": minor +--- + +Add support for next config `basePath` diff --git a/examples/_shared/package.json b/examples/_shared/package.json new file mode 100644 index 0000000..3024e64 --- /dev/null +++ b/examples/_shared/package.json @@ -0,0 +1,24 @@ +{ + "name": "shared", + "private": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./chat-room": { + "types": "./dist/chat-room/index.d.ts", + "import": "./dist/chat-room/index.js" + } + }, + "scripts": { + "build": "tsc", + "postinstall": "pnpm build" + }, + "dependencies": { + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.8" + } +} diff --git a/examples/_shared/src/chat-room/index.ts b/examples/_shared/src/chat-room/index.ts new file mode 100644 index 0000000..3ea11a1 --- /dev/null +++ b/examples/_shared/src/chat-room/index.ts @@ -0,0 +1,3 @@ +export * from './message-list'; +export * from './message-submit'; +export * from './messaging'; diff --git a/examples/chat-room-with-custom-server/app/chat/_shared/message-list.tsx b/examples/_shared/src/chat-room/message-list.tsx similarity index 95% rename from examples/chat-room-with-custom-server/app/chat/_shared/message-list.tsx rename to examples/_shared/src/chat-room/message-list.tsx index e165782..72974fb 100644 --- a/examples/chat-room-with-custom-server/app/chat/_shared/message-list.tsx +++ b/examples/_shared/src/chat-room/message-list.tsx @@ -1,4 +1,4 @@ -import type { Message } from './message'; +import type { Message } from './messaging'; export function MessageList({ messages }: { messages: Message[] }) { return ( diff --git a/examples/chat-room-with-custom-server/app/chat/_shared/message-submit.tsx b/examples/_shared/src/chat-room/message-submit.tsx similarity index 91% rename from examples/chat-room-with-custom-server/app/chat/_shared/message-submit.tsx rename to examples/_shared/src/chat-room/message-submit.tsx index 0e3d0cb..a063888 100644 --- a/examples/chat-room-with-custom-server/app/chat/_shared/message-submit.tsx +++ b/examples/_shared/src/chat-room/message-submit.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback } from 'react'; -import type { Message } from './message'; +import type { Message } from './messaging'; export function MessageSubmit({ onMessage, @@ -21,7 +21,7 @@ export function MessageSubmit({ // Reset the content input (only) const contentInputElement = event.currentTarget // .querySelector('input[name="content"]'); - contentInputElement.value = ''; + if (contentInputElement) contentInputElement.value = ''; }, [onMessage], ); diff --git a/examples/chat-room/app/chat/_shared/websocket.ts b/examples/_shared/src/chat-room/messaging.ts similarity index 59% rename from examples/chat-room/app/chat/_shared/websocket.ts rename to examples/_shared/src/chat-room/messaging.ts index 776e63d..f061e76 100644 --- a/examples/chat-room/app/chat/_shared/websocket.ts +++ b/examples/_shared/src/chat-room/messaging.ts @@ -1,25 +1,24 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { Message } from './message'; +import { useCallback, useEffect, useState } from 'react'; +import { useWebSocket } from '../websocket'; + +export interface Message { + author: string; + content: string; +} export function useMessaging(url: () => string) { - const ref = useRef(null); - const target = useRef(url); + const socket = useWebSocket(url); const [messages, setMessages] = useState([]); useEffect(() => { - if (ref.current) return; - const socket = new WebSocket(target.current()); - ref.current = socket; - const controller = new AbortController(); - socket.addEventListener( + socket?.addEventListener( 'message', async (event) => { - console.log('Incoming event:', event); const payload = typeof event.data === 'string' ? event.data : await event.data.text(); const message = JSON.parse(payload) as Message; @@ -29,7 +28,7 @@ export function useMessaging(url: () => string) { controller, ); - socket.addEventListener( + socket?.addEventListener( 'error', () => { const content = 'An error occurred while connecting to the server'; @@ -38,7 +37,7 @@ export function useMessaging(url: () => string) { controller, ); - socket.addEventListener( + socket?.addEventListener( 'close', (event) => { if (event.wasClean) return; @@ -49,14 +48,17 @@ export function useMessaging(url: () => string) { ); return () => controller.abort(); - }, []); - - const sendMessage = useCallback((message: Message) => { - if (!ref.current || ref.current.readyState !== ref.current.OPEN) return; - console.log('Outgoing message:', message); - ref.current.send(JSON.stringify(message)); - setMessages((p) => [...p, { ...message, author: 'You' }]); - }, []); + }, [socket]); + + const sendMessage = useCallback( + (message: Message) => { + if (!socket || socket.readyState !== socket.OPEN) return; + console.log('Outgoing message:', message); + socket.send(JSON.stringify(message)); + setMessages((p) => [...p, { ...message, author: 'You' }]); + }, + [socket], + ); return [messages, sendMessage] as const; } diff --git a/examples/_shared/src/index.ts b/examples/_shared/src/index.ts new file mode 100644 index 0000000..98111c5 --- /dev/null +++ b/examples/_shared/src/index.ts @@ -0,0 +1 @@ +export * from './websocket'; diff --git a/examples/_shared/src/websocket.ts b/examples/_shared/src/websocket.ts new file mode 100644 index 0000000..cdaffae --- /dev/null +++ b/examples/_shared/src/websocket.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +export function useWebSocket(url: () => string) { + const ref = useRef(null); + const target = useRef(url); + const [, update] = useState(0); + + useEffect(() => { + if (ref.current) return; + const socket = new WebSocket(target.current()); + ref.current = socket; + update((p) => p + 1); + + return () => socket.close(); + }, []); + + return ref.current; +} diff --git a/examples/_shared/tsconfig.json b/examples/_shared/tsconfig.json new file mode 100644 index 0000000..ba7bbfe --- /dev/null +++ b/examples/_shared/tsconfig.json @@ -0,0 +1,43 @@ +{ + "include": ["src/**/*"], + "exclude": ["node_modules"], + + "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, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + + "declaration": true, + "declarationMap": true, + "importHelpers": false, + "newLine": "lf", + "noEmitHelpers": true, + "removeComments": false, + "sourceMap": true, + + "experimentalDecorators": true, + "lib": ["DOM", "ESNext"], + "target": "ES2022", + "useDefineForClassFields": true, + "skipLibCheck": true, + + "outDir": "./dist", + "paths": { "~/*": ["./src/*"] }, + + "jsx": "preserve" + } +} diff --git a/examples/chat-room-with-custom-server/app/chat/simple/socket/route.ts b/examples/base-path/app/(simple)/api/ws/route.ts similarity index 100% rename from examples/chat-room-with-custom-server/app/chat/simple/socket/route.ts rename to examples/base-path/app/(simple)/api/ws/route.ts diff --git a/examples/chat-room-with-custom-server/app/chat/simple/page.tsx b/examples/base-path/app/(simple)/page.tsx similarity index 53% rename from examples/chat-room-with-custom-server/app/chat/simple/page.tsx rename to examples/base-path/app/(simple)/page.tsx index 6ce6b73..78ba37e 100644 --- a/examples/chat-room-with-custom-server/app/chat/simple/page.tsx +++ b/examples/base-path/app/(simple)/page.tsx @@ -1,12 +1,10 @@ 'use client'; -import { MessageList } from '../_shared/message-list'; -import { MessageSubmit } from '../_shared/message-submit'; -import { useMessaging } from '../_shared/websocket'; +import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; export default function Page() { const [messages, sendMessage] = useMessaging( - () => `ws://${window.location.host}/chat/simple/socket`, + () => `ws://${window.location.host}/some-base-path/api/ws`, ); return ( diff --git a/examples/chat-room-with-custom-server/app/layout.tsx b/examples/base-path/app/layout.tsx similarity index 100% rename from examples/chat-room-with-custom-server/app/layout.tsx rename to examples/base-path/app/layout.tsx diff --git a/examples/chat-room-with-custom-server/next-env.d.ts b/examples/base-path/next-env.d.ts similarity index 100% rename from examples/chat-room-with-custom-server/next-env.d.ts rename to examples/base-path/next-env.d.ts diff --git a/examples/base-path/next.config.mjs b/examples/base-path/next.config.mjs new file mode 100644 index 0000000..9e1860c --- /dev/null +++ b/examples/base-path/next.config.mjs @@ -0,0 +1,3 @@ +export default { + basePath: '/some-base-path', +}; diff --git a/examples/base-path/package.json b/examples/base-path/package.json new file mode 100644 index 0000000..4a10649 --- /dev/null +++ b/examples/base-path/package.json @@ -0,0 +1,20 @@ +{ + "name": "@examples/base-path", + "private": true, + "scripts": { + "build": "next build", + "start": "next start", + "dev": "next dev", + "prepare": "next-ws patch" + }, + "dependencies": { + "next": "15.2.3", + "next-ws": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "shared": "workspace:^" + }, + "devDependencies": { + "@types/react": "^19.0.8" + } +} diff --git a/examples/base-path/tsconfig.json b/examples/base-path/tsconfig.json new file mode 100644 index 0000000..38a3782 --- /dev/null +++ b/examples/base-path/tsconfig.json @@ -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 + } +} diff --git a/examples/chat-room-with-custom-server/app/chat/_shared/message.ts b/examples/chat-room-with-custom-server/app/chat/_shared/message.ts deleted file mode 100644 index 7c72dda..0000000 --- a/examples/chat-room-with-custom-server/app/chat/_shared/message.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Message { - author: string; - content: string; -} diff --git a/examples/chat-room-with-custom-server/app/chat/_shared/websocket.ts b/examples/chat-room-with-custom-server/app/chat/_shared/websocket.ts deleted file mode 100644 index 776e63d..0000000 --- a/examples/chat-room-with-custom-server/app/chat/_shared/websocket.ts +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { Message } from './message'; - -export function useMessaging(url: () => string) { - const ref = useRef(null); - const target = useRef(url); - - const [messages, setMessages] = useState([]); - - useEffect(() => { - if (ref.current) return; - const socket = new WebSocket(target.current()); - ref.current = socket; - - const controller = new AbortController(); - - socket.addEventListener( - 'message', - async (event) => { - console.log('Incoming event:', event); - const payload = - typeof event.data === 'string' ? event.data : await event.data.text(); - const message = JSON.parse(payload) as Message; - console.log('Incoming message:', message); - setMessages((p) => [...p, message]); - }, - controller, - ); - - socket.addEventListener( - 'error', - () => { - const content = 'An error occurred while connecting to the server'; - setMessages((p) => [...p, { author: 'System', content }]); - }, - controller, - ); - - socket.addEventListener( - 'close', - (event) => { - if (event.wasClean) return; - const content = 'The connection to the server was closed unexpectedly'; - setMessages((p) => [...p, { author: 'System', content }]); - }, - controller, - ); - - return () => controller.abort(); - }, []); - - const sendMessage = useCallback((message: Message) => { - if (!ref.current || ref.current.readyState !== ref.current.OPEN) return; - console.log('Outgoing message:', message); - ref.current.send(JSON.stringify(message)); - setMessages((p) => [...p, { ...message, author: 'You' }]); - }, []); - - return [messages, sendMessage] as const; -} diff --git a/examples/chat-room-with-custom-server/next.config.js b/examples/chat-room-with-custom-server/next.config.js deleted file mode 100644 index f053ebf..0000000 --- a/examples/chat-room-with-custom-server/next.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/examples/chat-room-with-custom-server/tsconfig.json b/examples/chat-room-with-custom-server/tsconfig.json deleted file mode 100644 index 4b34f80..0000000 --- a/examples/chat-room-with-custom-server/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "include": ["app/**/*", "server.ts", ".next/types/**/*.ts"], - "exclude": ["node_modules"], - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": false, - "noEmit": true, - "incremental": true, - "module": "esnext", - "esModuleInterop": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "plugins": [{ "name": "next" }], - "forceConsistentCasingInFileNames": true - } -} diff --git a/examples/chat-room/app/chat/dynamic/[language]/socket/route.ts b/examples/chat-room/app/(dynamic)/[code]/api/ws/route.ts similarity index 77% rename from examples/chat-room/app/chat/dynamic/[language]/socket/route.ts rename to examples/chat-room/app/(dynamic)/[code]/api/ws/route.ts index cad10c8..932fe80 100644 --- a/examples/chat-room/app/chat/dynamic/[language]/socket/route.ts +++ b/examples/chat-room/app/(dynamic)/[code]/api/ws/route.ts @@ -9,14 +9,14 @@ export function SOCKET( client: import('ws').WebSocket, _request: import('node:http').IncomingMessage, server: import('ws').WebSocketServer, - { params }: { params: { language: string } }, + { params: { code } }: { params: { code: string } }, ) { for (const other of server.clients) { if (client === other || other.readyState !== other.OPEN) continue; other.send( JSON.stringify({ author: 'System', - content: `A new user joined the chat, their language is ${params.language}`, + content: `A new user joined the ${code} chat.`, }), ); } @@ -31,7 +31,7 @@ export function SOCKET( client.send( JSON.stringify({ author: 'System', - content: `Welcome to the chat! Your language is ${params.language}. There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, + content: `Welcome to the ${code} chat!. There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, }), ); diff --git a/examples/chat-room/app/chat/simple/page.tsx b/examples/chat-room/app/(dynamic)/[code]/page.tsx similarity index 53% rename from examples/chat-room/app/chat/simple/page.tsx rename to examples/chat-room/app/(dynamic)/[code]/page.tsx index 6ce6b73..e85c050 100644 --- a/examples/chat-room/app/chat/simple/page.tsx +++ b/examples/chat-room/app/(dynamic)/[code]/page.tsx @@ -1,12 +1,12 @@ 'use client'; -import { MessageList } from '../_shared/message-list'; -import { MessageSubmit } from '../_shared/message-submit'; -import { useMessaging } from '../_shared/websocket'; +import { useParams } from 'next/navigation'; +import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; export default function Page() { + const { code } = useParams(); const [messages, sendMessage] = useMessaging( - () => `ws://${window.location.host}/chat/simple/socket`, + () => `ws://${window.location.host}/${code}/api/ws`, ); return ( diff --git a/examples/chat-room/app/chat/simple/socket/route.ts b/examples/chat-room/app/(simple)/api/ws/route.ts similarity index 100% rename from examples/chat-room/app/chat/simple/socket/route.ts rename to examples/chat-room/app/(simple)/api/ws/route.ts diff --git a/examples/chat-room-with-custom-server/app/chat/dynamic/page.tsx b/examples/chat-room/app/(simple)/page.tsx similarity index 50% rename from examples/chat-room-with-custom-server/app/chat/dynamic/page.tsx rename to examples/chat-room/app/(simple)/page.tsx index ad449e7..764a394 100644 --- a/examples/chat-room-with-custom-server/app/chat/dynamic/page.tsx +++ b/examples/chat-room/app/(simple)/page.tsx @@ -1,13 +1,10 @@ 'use client'; -import { MessageList } from '../_shared/message-list'; -import { MessageSubmit } from '../_shared/message-submit'; -import { useMessaging } from '../_shared/websocket'; +import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; export default function Page() { const [messages, sendMessage] = useMessaging( - () => - `ws://${window.location.host}/chat/dynamic/${navigator.language}/socket`, + () => `ws://${window.location.host}/api/ws`, ); return ( diff --git a/examples/chat-room/app/chat/_shared/message-list.tsx b/examples/chat-room/app/chat/_shared/message-list.tsx deleted file mode 100644 index e165782..0000000 --- a/examples/chat-room/app/chat/_shared/message-list.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { Message } from './message'; - -export function MessageList({ messages }: { messages: Message[] }) { - return ( -
    - {messages.map((message, i) => ( -
  • - {message.author}: {message.content} -
  • - ))} - - {messages.length === 0 && ( -
    -

    Waiting for messages...

    -
    - )} -
- ); -} diff --git a/examples/chat-room/app/chat/_shared/message-submit.tsx b/examples/chat-room/app/chat/_shared/message-submit.tsx deleted file mode 100644 index 0e3d0cb..0000000 --- a/examples/chat-room/app/chat/_shared/message-submit.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useCallback } from 'react'; -import type { Message } from './message'; - -export function MessageSubmit({ - onMessage, -}: { - onMessage(message: Message): void; -}) { - const handleSubmit = useCallback( - (event: React.FormEvent) => { - event.preventDefault(); - const form = new FormData(event.currentTarget); - const author = form.get('author') as string; - const content = form.get('content') as string; - if (!author || !content) return; - - onMessage({ author, content }); - - // Reset the content input (only) - const contentInputElement = event.currentTarget // - .querySelector('input[name="content"]'); - contentInputElement.value = ''; - }, - [onMessage], - ); - - return ( -
- - - -
- ); -} diff --git a/examples/chat-room/app/chat/_shared/message.ts b/examples/chat-room/app/chat/_shared/message.ts deleted file mode 100644 index 7c72dda..0000000 --- a/examples/chat-room/app/chat/_shared/message.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Message { - author: string; - content: string; -} diff --git a/examples/chat-room/next.config.js b/examples/chat-room/next.config.js deleted file mode 100644 index f053ebf..0000000 --- a/examples/chat-room/next.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/examples/chat-room/package.json b/examples/chat-room/package.json index cbdefc3..c78d6e3 100644 --- a/examples/chat-room/package.json +++ b/examples/chat-room/package.json @@ -1,5 +1,5 @@ { - "name": "chat-room", + "name": "@examples/chat-room", "private": true, "scripts": { "build": "next build", @@ -11,7 +11,8 @@ "next": "15.2.3", "next-ws": "workspace:^", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "shared": "workspace:^" }, "devDependencies": { "@types/react": "^19.0.8" diff --git a/examples/chat-room/tsconfig.json b/examples/chat-room/tsconfig.json index acda960..38a3782 100644 --- a/examples/chat-room/tsconfig.json +++ b/examples/chat-room/tsconfig.json @@ -1,21 +1,56 @@ { + "$schema": "https://json.schemastore.org/tsconfig.json", + "include": ["app/**/*", ".next/types/**/*.ts"], - "exclude": ["node_modules"], + "exclude": ["node_modules", ".next"], + + "compileOnSave": true, "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "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, - "skipLibCheck": true, - "strict": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "resolvePackageJsonExports": true, + "resolvePackageJsonImports": true, + + "declaration": true, + "declarationMap": true, + "importHelpers": false, + "newLine": "lf", "noEmit": true, - "incremental": true, - "module": "esnext", + "noEmitHelpers": true, + "removeComments": false, + "sourceMap": true, + "allowSyntheticDefaultImports": true, "esModuleInterop": true, - "moduleResolution": "node", - "resolveJsonModule": 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" }], - "forceConsistentCasingInFileNames": true + "incremental": true } } diff --git a/examples/chat-room-with-custom-server/app/chat/dynamic/[language]/socket/route.ts b/examples/custom-server/app/(dynamic)/[code]/api/ws/route.ts similarity index 77% rename from examples/chat-room-with-custom-server/app/chat/dynamic/[language]/socket/route.ts rename to examples/custom-server/app/(dynamic)/[code]/api/ws/route.ts index cad10c8..932fe80 100644 --- a/examples/chat-room-with-custom-server/app/chat/dynamic/[language]/socket/route.ts +++ b/examples/custom-server/app/(dynamic)/[code]/api/ws/route.ts @@ -9,14 +9,14 @@ export function SOCKET( client: import('ws').WebSocket, _request: import('node:http').IncomingMessage, server: import('ws').WebSocketServer, - { params }: { params: { language: string } }, + { params: { code } }: { params: { code: string } }, ) { for (const other of server.clients) { if (client === other || other.readyState !== other.OPEN) continue; other.send( JSON.stringify({ author: 'System', - content: `A new user joined the chat, their language is ${params.language}`, + content: `A new user joined the ${code} chat.`, }), ); } @@ -31,7 +31,7 @@ export function SOCKET( client.send( JSON.stringify({ author: 'System', - content: `Welcome to the chat! Your language is ${params.language}. There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, + content: `Welcome to the ${code} chat!. There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, }), ); diff --git a/examples/chat-room/app/chat/dynamic/page.tsx b/examples/custom-server/app/(dynamic)/[code]/page.tsx similarity index 50% rename from examples/chat-room/app/chat/dynamic/page.tsx rename to examples/custom-server/app/(dynamic)/[code]/page.tsx index ad449e7..e85c050 100644 --- a/examples/chat-room/app/chat/dynamic/page.tsx +++ b/examples/custom-server/app/(dynamic)/[code]/page.tsx @@ -1,13 +1,12 @@ 'use client'; -import { MessageList } from '../_shared/message-list'; -import { MessageSubmit } from '../_shared/message-submit'; -import { useMessaging } from '../_shared/websocket'; +import { useParams } from 'next/navigation'; +import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; export default function Page() { + const { code } = useParams(); const [messages, sendMessage] = useMessaging( - () => - `ws://${window.location.host}/chat/dynamic/${navigator.language}/socket`, + () => `ws://${window.location.host}/${code}/api/ws`, ); return ( diff --git a/examples/custom-server/app/(simple)/api/ws/route.ts b/examples/custom-server/app/(simple)/api/ws/route.ts new file mode 100644 index 0000000..0a3cad4 --- /dev/null +++ b/examples/custom-server/app/(simple)/api/ws/route.ts @@ -0,0 +1,48 @@ +export function GET() { + const headers = new Headers(); + headers.set('Connection', 'Upgrade'); + headers.set('Upgrade', 'websocket'); + return new Response('Upgrade Required', { status: 426, headers }); +} + +export function SOCKET( + client: import('ws').WebSocket, + _request: import('node:http').IncomingMessage, + server: import('ws').WebSocketServer, +) { + for (const other of server.clients) { + if (client === other || other.readyState !== other.OPEN) continue; + other.send( + JSON.stringify({ + author: 'System', + content: 'A new user joined the chat', + }), + ); + } + + client.on('message', (message) => { + // Forward the message to all other clients + for (const other of server.clients) + if (client !== other && other.readyState === other.OPEN) + other.send(message); + }); + + client.send( + JSON.stringify({ + author: 'System', + content: `Welcome to the chat! There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, + }), + ); + + return () => { + for (const other of server.clients) { + if (client === other || other.readyState !== other.OPEN) continue; + other.send( + JSON.stringify({ + author: 'System', + content: 'A user left the chat', + }), + ); + } + }; +} diff --git a/examples/custom-server/app/(simple)/page.tsx b/examples/custom-server/app/(simple)/page.tsx new file mode 100644 index 0000000..764a394 --- /dev/null +++ b/examples/custom-server/app/(simple)/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/examples/custom-server/app/layout.tsx b/examples/custom-server/app/layout.tsx new file mode 100644 index 0000000..6fb2199 --- /dev/null +++ b/examples/custom-server/app/layout.tsx @@ -0,0 +1,17 @@ +export default function Layout({ children }: React.PropsWithChildren) { + return ( + + + {children} + + + ); +} diff --git a/examples/custom-server/next-env.d.ts b/examples/custom-server/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/examples/custom-server/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/chat-room-with-custom-server/package.json b/examples/custom-server/package.json similarity index 74% rename from examples/chat-room-with-custom-server/package.json rename to examples/custom-server/package.json index 3221632..a192a64 100644 --- a/examples/chat-room-with-custom-server/package.json +++ b/examples/custom-server/package.json @@ -1,5 +1,5 @@ { - "name": "chat-room-with-custom-server", + "name": "@examples/custom-server", "private": true, "scripts": { "build": "next build", @@ -8,10 +8,11 @@ "prepare": "next-ws patch" }, "dependencies": { - "next": "^15.2.3", + "next": "15.2.3", "next-ws": "workspace:^", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "shared": "workspace:^" }, "devDependencies": { "@types/react": "^19.0.8", diff --git a/examples/chat-room-with-custom-server/server.ts b/examples/custom-server/server.ts similarity index 100% rename from examples/chat-room-with-custom-server/server.ts rename to examples/custom-server/server.ts diff --git a/examples/custom-server/tsconfig.json b/examples/custom-server/tsconfig.json new file mode 100644 index 0000000..38a3782 --- /dev/null +++ b/examples/custom-server/tsconfig.json @@ -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 + } +} diff --git a/playwright.config.ts b/playwright.config.ts index 06c6bcc..d6a465f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,11 +15,17 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, }, { - cwd: 'examples/chat-room-with-custom-server', - env: { PORT: '3002' }, - command: 'pnpm dev', + cwd: 'examples/base-path', + command: 'pnpm dev --port 3002', port: 3002, reuseExistingServer: !process.env.CI, }, + { + cwd: 'examples/custom-server', + command: 'pnpm dev', + env: { PORT: '3003' }, + port: 3003, + reuseExistingServer: !process.env.CI, + }, ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c892fd..fbda7d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,38 @@ importers: specifier: ^5.7.3 version: 5.7.3 + examples/_shared: + dependencies: + react: + specifier: ^19.0.0 + version: 19.0.0 + devDependencies: + '@types/react': + specifier: ^19.0.8 + version: 19.0.10 + + examples/base-path: + dependencies: + next: + specifier: 15.2.3 + version: 15.2.3(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next-ws: + specifier: workspace:^ + version: link:../.. + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + shared: + specifier: workspace:^ + version: link:../_shared + devDependencies: + '@types/react': + specifier: ^19.0.8 + version: 19.0.10 + examples/chat-room: dependencies: next: @@ -81,15 +113,18 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + shared: + specifier: workspace:^ + version: link:../_shared devDependencies: '@types/react': specifier: ^19.0.8 version: 19.0.10 - examples/chat-room-with-custom-server: + examples/custom-server: dependencies: next: - specifier: ^15.2.3 + specifier: 15.2.3 version: 15.2.3(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-ws: specifier: workspace:^ @@ -100,6 +135,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + shared: + specifier: workspace:^ + version: link:../_shared devDependencies: '@types/react': specifier: ^19.0.8 @@ -964,9 +1002,6 @@ packages: caniuse-lite@1.0.30001700: resolution: {integrity: sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==} - caniuse-lite@1.0.30001705: - resolution: {integrity: sha512-S0uyMMiYvA7CxNgomYBwwwPUnWzFD83f3B1ce5jHUfHTH//QL6hHsreI8RVC5606R4ssqravelYO5TU6t8sEyg==} - chalk@5.4.1: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2306,8 +2341,6 @@ snapshots: caniuse-lite@1.0.30001700: {} - caniuse-lite@1.0.30001705: {} - chalk@5.4.1: {} chardet@0.7.0: {} @@ -2639,7 +2672,7 @@ snapshots: '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 - caniuse-lite: 1.0.30001705 + caniuse-lite: 1.0.30001700 postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) diff --git a/src/server/helpers/route.ts b/src/server/helpers/route.ts index aea36ed..65c727c 100644 --- a/src/server/helpers/route.ts +++ b/src/server/helpers/route.ts @@ -49,6 +49,8 @@ export function resolvePathToRoute( nextServer: NextNodeServer, requestPath: string, ) { + // @ts-expect-error - serverOptions is protected + const basePath = nextServer.serverOptions.conf.basePath; const routes = { // @ts-expect-error - appPathRoutes is protected ...nextServer.appPathRoutes, @@ -57,7 +59,8 @@ export function resolvePathToRoute( }; for (const [routePath, [filePath]] of Object.entries(routes)) { - const routeParams = getRouteParams(routePath, requestPath); + const realPath = `${basePath}${routePath}`; + const routeParams = getRouteParams(realPath, requestPath); if (routeParams) return { filePath: filePath!, routeParams }; } diff --git a/src/server/setup.ts b/src/server/setup.ts index 2224f88..1e6ee02 100644 --- a/src/server/setup.ts +++ b/src/server/setup.ts @@ -27,7 +27,7 @@ export function setupWebSocketServer(nextServer: NextNodeServer) { httpServer.on('upgrade', async (request, socket, head) => { const url = new URL(request.url ?? '', 'ws://next'); const pathname = url.pathname; - if (pathname.startsWith('/_next')) return; + if (pathname.includes('/_next')) return; const routeInfo = resolvePathToRoute(nextServer, pathname); if (!routeInfo) { diff --git a/tests/base-path.test.ts b/tests/base-path.test.ts new file mode 100644 index 0000000..a0b7384 --- /dev/null +++ b/tests/base-path.test.ts @@ -0,0 +1,56 @@ +import { type Page, expect, test } from '@playwright/test'; + +// biome-ignore lint/style/useSingleVarDeclarator: I do what I want +let page1: Page, page2: Page; +test.beforeEach(async ({ browser }) => { + page1 = await browser.newPage(); + page2 = await browser.newPage(); +}); +test.afterEach(async () => { + await page1.close(); + await page2.close(); +}); + +test.use({ baseURL: 'http://localhost:3002' }); + +test.describe('Chat Room', () => { + test('a user joins the chat and receives a welcome message', async () => { + await page1.goto('/some-base-path'); + + const welcome1 = await page1.textContent('li:first-child'); + expect(welcome1).toContain('Welcome to the chat!'); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto('/some-base-path'); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain('Welcome to the chat!'); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message', async () => { + await page1.goto('/some-base-path'); + await page2.goto('/some-base-path'); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('A new user joined the chat'); + }); + + test('a user sends a message and all users receive it', async () => { + await page1.goto('/some-base-path'); + await page2.goto('/some-base-path'); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); diff --git a/tests/chat-room-with-custom-server.test.ts b/tests/chat-room-with-custom-server.test.ts deleted file mode 100644 index ffc5707..0000000 --- a/tests/chat-room-with-custom-server.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type Page, expect, test } from '@playwright/test'; - -test.use({ baseURL: 'http://localhost:3002' }); - -// biome-ignore lint/style/useSingleVarDeclarator: I do what I want -let page1: Page, page2: Page; -test.beforeEach(async ({ browser }) => { - page1 = await browser.newPage(); - const context2 = await browser.newContext({ locale: 'fr-FR' }); - page2 = await context2.newPage(); -}); -test.afterEach(async () => { - await page1.close(); - await page2.close(); -}); - -test.describe('Chat Room', () => { - test('a user joins the chat and receives a welcome message', async () => { - await page1.goto('/chat/simple'); - - const welcome1 = await page1.textContent('li:first-child'); - expect(welcome1).toContain('Welcome to the chat!'); - expect(welcome1).toContain('There are no other users online'); - - await page2.goto('/chat/simple'); - - const welcome2 = await page2.textContent('li:first-child'); - expect(welcome2).toContain('Welcome to the chat!'); - expect(welcome2).toContain('There is 1 other user online'); - }); - - test('a new user joins the chat and all users receive a message', async () => { - await page1.goto('/chat/simple'); - await page2.goto('/chat/simple'); - - await page1.waitForTimeout(1000); // Can take a moment - const message1 = await page1.textContent('li:last-child'); - expect(message1).toContain('A new user joined the chat'); - }); - - test('a user sends a message and all users receive it', async () => { - await page1.goto('/chat/simple'); - await page2.goto('/chat/simple'); - - await page1.fill('input[name=author]', 'Alice'); - await page1.fill('input[name=content]', 'Hello, world!'); - await page1.click('button[type=submit]'); - - const message1 = await page1.textContent('li:last-child'); - expect(message1).toContain('You'); - expect(message1).toContain('Hello, world!'); - - const message2 = await page2.textContent('li:last-child'); - expect(message2).toContain('Alice'); - expect(message2).toContain('Hello, world!'); - }); -}); - -test.describe('Chat Room with Dynamic Socket Route', () => { - test('a user joins the chat and receives a welcome message with their dynamic value', async () => { - await page1.goto('/chat/dynamic'); - - const welcome1 = await page1.textContent('li:first-child'); - expect(welcome1).toContain('Welcome to the chat!'); - expect(welcome1).toContain('Your language is en-US'); - expect(welcome1).toContain('There are no other users online'); - - await page2.goto('/chat/dynamic'); - - const welcome2 = await page2.textContent('li:first-child'); - expect(welcome2).toContain('Welcome to the chat!'); - expect(welcome2).toContain('Your language is fr-FR'); - expect(welcome2).toContain('There is 1 other user online'); - }); - - test('a new user joins the chat and all users receive a message with their dynamic value', async () => { - await page1.goto('/chat/dynamic'); - await page2.goto('/chat/dynamic'); - - await page1.waitForTimeout(1000); // Can take a moment - const message1 = await page1.textContent('li:last-child'); - expect(message1).toContain('A new user joined the chat'); - expect(message1).toContain('their language is fr-FR'); - }); - - test('a user sends a message and all users receive it', async () => { - await page1.goto('/chat/dynamic'); - await page2.goto('/chat/dynamic'); - - await page1.fill('input[name=author]', 'Alice'); - await page1.fill('input[name=content]', 'Hello, world!'); - await page1.click('button[type=submit]'); - - const message1 = await page1.textContent('li:last-child'); - expect(message1).toContain('You'); - expect(message1).toContain('Hello, world!'); - - const message2 = await page2.textContent('li:last-child'); - expect(message2).toContain('Alice'); - expect(message2).toContain('Hello, world!'); - }); -}); diff --git a/tests/chat-room.test.ts b/tests/chat-room.test.ts index 101539e..586eba4 100644 --- a/tests/chat-room.test.ts +++ b/tests/chat-room.test.ts @@ -1,28 +1,27 @@ import { type Page, expect, test } from '@playwright/test'; -test.use({ baseURL: 'http://localhost:3001' }); - // biome-ignore lint/style/useSingleVarDeclarator: I do what I want let page1: Page, page2: Page; test.beforeEach(async ({ browser }) => { page1 = await browser.newPage(); - const context2 = await browser.newContext({ locale: 'fr-FR' }); - page2 = await context2.newPage(); + page2 = await browser.newPage(); }); test.afterEach(async () => { await page1.close(); await page2.close(); }); +test.use({ baseURL: 'http://localhost:3001' }); + test.describe('Chat Room', () => { test('a user joins the chat and receives a welcome message', async () => { - await page1.goto('/chat/simple'); + await page1.goto('/'); const welcome1 = await page1.textContent('li:first-child'); expect(welcome1).toContain('Welcome to the chat!'); expect(welcome1).toContain('There are no other users online'); - await page2.goto('/chat/simple'); + await page2.goto('/'); const welcome2 = await page2.textContent('li:first-child'); expect(welcome2).toContain('Welcome to the chat!'); @@ -30,8 +29,8 @@ test.describe('Chat Room', () => { }); test('a new user joins the chat and all users receive a message', async () => { - await page1.goto('/chat/simple'); - await page2.goto('/chat/simple'); + await page1.goto('/'); + await page2.goto('/'); await page1.waitForTimeout(1000); // Can take a moment const message1 = await page1.textContent('li:last-child'); @@ -39,8 +38,8 @@ test.describe('Chat Room', () => { }); test('a user sends a message and all users receive it', async () => { - await page1.goto('/chat/simple'); - await page2.goto('/chat/simple'); + await page1.goto('/'); + await page2.goto('/'); await page1.fill('input[name=author]', 'Alice'); await page1.fill('input[name=content]', 'Hello, world!'); @@ -56,26 +55,132 @@ test.describe('Chat Room', () => { }); }); -test.describe('Chat Room with Dynamic Socket Route', () => { +test.describe('Private Chat Room', () => { test('a user joins the chat and receives a welcome message with their dynamic value', async () => { - await page1.goto('/chat/dynamic'); + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); const welcome1 = await page1.textContent('li:first-child'); + expect(welcome1).toContain(`Welcome to the ${code} chat!`); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto(`/${code}`); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain(`Welcome to the ${code} chat!`); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message with their dynamic value', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + await page2.goto(`/${code}`); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain(`A new user joined the ${code} chat`); + }); + + test('a user sends a message and all users receive it', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + await page2.goto(`/${code}`); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); + +/* +test.use({ baseURL: 'http://localhost:3001' }); + +// biome-ignore lint/style/useSingleVarDeclarator: I do what I want +let page1: Page, page2: Page; +test.beforeEach(async ({ browser }) => { + page1 = await browser.newPage(); + page2 = await browser.newPage(); +}); +test.afterEach(async () => { + await page1.close(); + await page2.close(); +}); + +test.describe('Chat Room', () => { + test('a user joins the chat and receives a welcome message', async () => { + await page1.goto('/'); + + const welcome1 = await page2.textContent('li:first-child'); expect(welcome1).toContain('Welcome to the chat!'); - expect(welcome1).toContain('Your language is en-US'); expect(welcome1).toContain('There are no other users online'); - await page2.goto('/chat/dynamic'); + await page2.goto('/'); const welcome2 = await page2.textContent('li:first-child'); expect(welcome2).toContain('Welcome to the chat!'); - expect(welcome2).toContain('Your language is fr-FR'); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message', async () => { + await page1.goto('/'); + await page2.goto('/'); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('A new user joined the chat'); + }); + + test('a user sends a message and all users receive it', async () => { + await page1.goto('/'); + await page2.goto('/'); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); + +test.describe('Private Chat Room', () => { + test('a user joins the chat and receives a welcome message with their dynamic value', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + + const welcome1 = await page1.textContent('li:first-child'); + expect(welcome1).toContain(`Welcome to the ${code} chat!`); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto(`/${code}`); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain(`Welcome to the ${code} chat!`); expect(welcome2).toContain('There is 1 other user online'); }); test('a new user joins the chat and all users receive a message with their dynamic value', async () => { - await page1.goto('/chat/dynamic'); - await page2.goto('/chat/dynamic'); + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`${code}`); + await page2.goto(`${code}`); await page1.waitForTimeout(1000); // Can take a moment const message1 = await page1.textContent('li:last-child'); @@ -84,8 +189,10 @@ test.describe('Chat Room with Dynamic Socket Route', () => { }); test('a user sends a message and all users receive it', async () => { - await page1.goto('/chat/dynamic'); - await page2.goto('/chat/dynamic'); + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`${code}`); + await page2.goto(`${code}`); await page1.fill('input[name=author]', 'Alice'); await page1.fill('input[name=content]', 'Hello, world!'); @@ -100,3 +207,4 @@ test.describe('Chat Room with Dynamic Socket Route', () => { expect(message2).toContain('Hello, world!'); }); }); +*/ diff --git a/tests/custom-server.test.ts b/tests/custom-server.test.ts new file mode 100644 index 0000000..0e3bef6 --- /dev/null +++ b/tests/custom-server.test.ts @@ -0,0 +1,210 @@ +import { type Page, expect, test } from '@playwright/test'; + +// biome-ignore lint/style/useSingleVarDeclarator: I do what I want +let page1: Page, page2: Page; +test.beforeEach(async ({ browser }) => { + page1 = await browser.newPage(); + page2 = await browser.newPage(); +}); +test.afterEach(async () => { + await page1.close(); + await page2.close(); +}); + +test.use({ baseURL: 'http://localhost:3003' }); + +test.describe('Chat Room', () => { + test('a user joins the chat and receives a welcome message', async () => { + await page1.goto('/'); + + const welcome1 = await page1.textContent('li:first-child'); + expect(welcome1).toContain('Welcome to the chat!'); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto('/'); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain('Welcome to the chat!'); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message', async () => { + await page1.goto('/'); + await page2.goto('/'); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('A new user joined the chat'); + }); + + test('a user sends a message and all users receive it', async () => { + await page1.goto('/'); + await page2.goto('/'); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); + +test.describe('Private Chat Room', () => { + test('a user joins the chat and receives a welcome message with their dynamic value', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + + const welcome1 = await page1.textContent('li:first-child'); + expect(welcome1).toContain(`Welcome to the ${code} chat!`); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto(`/${code}`); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain(`Welcome to the ${code} chat!`); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message with their dynamic value', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + await page2.goto(`/${code}`); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain(`A new user joined the ${code} chat`); + }); + + test('a user sends a message and all users receive it', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + await page2.goto(`/${code}`); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); + +/* +test.use({ baseURL: 'http://localhost:3001' }); + +// biome-ignore lint/style/useSingleVarDeclarator: I do what I want +let page1: Page, page2: Page; +test.beforeEach(async ({ browser }) => { + page1 = await browser.newPage(); + page2 = await browser.newPage(); +}); +test.afterEach(async () => { + await page1.close(); + await page2.close(); +}); + +test.describe('Chat Room', () => { + test('a user joins the chat and receives a welcome message', async () => { + await page1.goto('/'); + + const welcome1 = await page2.textContent('li:first-child'); + expect(welcome1).toContain('Welcome to the chat!'); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto('/'); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain('Welcome to the chat!'); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message', async () => { + await page1.goto('/'); + await page2.goto('/'); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('A new user joined the chat'); + }); + + test('a user sends a message and all users receive it', async () => { + await page1.goto('/'); + await page2.goto('/'); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); + +test.describe('Private Chat Room', () => { + test('a user joins the chat and receives a welcome message with their dynamic value', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`/${code}`); + + const welcome1 = await page1.textContent('li:first-child'); + expect(welcome1).toContain(`Welcome to the ${code} chat!`); + expect(welcome1).toContain('There are no other users online'); + + await page2.goto(`/${code}`); + + const welcome2 = await page2.textContent('li:first-child'); + expect(welcome2).toContain(`Welcome to the ${code} chat!`); + expect(welcome2).toContain('There is 1 other user online'); + }); + + test('a new user joins the chat and all users receive a message with their dynamic value', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`${code}`); + await page2.goto(`${code}`); + + await page1.waitForTimeout(1000); // Can take a moment + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('A new user joined the chat'); + expect(message1).toContain('their language is fr-FR'); + }); + + test('a user sends a message and all users receive it', async () => { + const code = Math.random().toString(16).slice(2, 6).toUpperCase(); + + await page1.goto(`${code}`); + await page2.goto(`${code}`); + + await page1.fill('input[name=author]', 'Alice'); + await page1.fill('input[name=content]', 'Hello, world!'); + await page1.click('button[type=submit]'); + + const message1 = await page1.textContent('li:last-child'); + expect(message1).toContain('You'); + expect(message1).toContain('Hello, world!'); + + const message2 = await page2.textContent('li:last-child'); + expect(message2).toContain('Alice'); + expect(message2).toContain('Hello, world!'); + }); +}); +*/