├── src ├── client │ ├── index.ts │ └── context.tsx ├── cli.ts ├── patches │ ├── index.ts │ ├── patch-2.ts │ ├── helpers │ │ ├── next.ts │ │ └── define.ts │ └── patch-1.ts ├── server │ ├── index.ts │ ├── helpers │ │ ├── socket.ts │ │ ├── request.ts │ │ ├── store.ts │ │ ├── match.ts │ │ └── module.ts │ ├── persistent.ts │ └── setup.ts └── commands │ ├── index.ts │ ├── helpers │ ├── semver.ts │ ├── console.ts │ └── define.ts │ ├── verify.ts │ └── patch.ts ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── actions │ └── node │ │ └── action.yml ├── workflows │ ├── pull-request-triage.yml │ ├── label-sync.yml │ ├── bump-next-version.yml │ └── pipeline.yml ├── pr-labels.yml └── labels.yml ├── examples ├── _shared │ ├── src │ │ ├── index.ts │ │ ├── chat-room │ │ │ ├── index.ts │ │ │ ├── message-list.tsx │ │ │ ├── message-submit.tsx │ │ │ └── messaging.ts │ │ └── websocket.ts │ ├── package.json │ └── tsconfig.json ├── base-path │ ├── next.config.mjs │ ├── next-env.d.ts │ ├── app │ │ ├── layout.tsx │ │ └── (simple) │ │ │ ├── page.tsx │ │ │ └── api │ │ │ └── ws │ │ │ └── route.ts │ ├── package.json │ └── tsconfig.json ├── custom-server │ ├── global.js │ ├── next-env.d.ts │ ├── app │ │ ├── (simple) │ │ │ ├── page.tsx │ │ │ └── api │ │ │ │ └── ws │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── (dynamic) │ │ │ └── [code] │ │ │ ├── page.tsx │ │ │ └── api │ │ │ └── ws │ │ │ └── route.ts │ ├── package.json │ ├── server.ts │ └── tsconfig.json └── chat-room │ ├── next-env.d.ts │ ├── app │ ├── (simple) │ │ ├── page.tsx │ │ └── api │ │ │ └── ws │ │ │ └── route.ts │ ├── layout.tsx │ └── (dynamic) │ │ └── [code] │ │ ├── page.tsx │ │ └── api │ │ └── ws │ │ └── route.ts │ ├── package.json │ └── tsconfig.json ├── .husky └── pre-commit ├── .gitignore ├── pnpm-workspace.yaml ├── .vscode ├── extensions.json └── settings.json ├── .changeset ├── config.json └── README.md ├── tsup.config.ts ├── playwright.config.ts ├── biome.json ├── tsconfig.json ├── tests ├── base-path.test.ts ├── chat-room.test.ts └── custom-server.test.ts ├── package.json ├── README.md └── CHANGELOG.md /src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from './context.jsx'; 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: apteryxxyz 2 | ko_fi: apteryx 3 | -------------------------------------------------------------------------------- /examples/_shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './websocket'; 2 | -------------------------------------------------------------------------------- /examples/base-path/next.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | basePath: '/some-base-path', 3 | }; 4 | -------------------------------------------------------------------------------- /examples/custom-server/global.js: -------------------------------------------------------------------------------- 1 | globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | pnpm biome check --staged --files-ignore-unknown=true --no-errors-on-unmatched 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Learn how to add code owners here: 2 | # https://help.github.com/en/articles/about-code-owners 3 | 4 | * @apteryxxyz -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from './commands/index.js'; 4 | 5 | program.parse([], process.argv.slice(2)); 6 | -------------------------------------------------------------------------------- /examples/_shared/src/chat-room/index.ts: -------------------------------------------------------------------------------- 1 | export * from './message-list'; 2 | export * from './message-submit'; 3 | export * from './messaging'; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules/ 3 | 4 | # Build 5 | dist/ 6 | .next/ 7 | 8 | # Test Results 9 | tests/.report/ 10 | tests/.results/ 11 | -------------------------------------------------------------------------------- /src/patches/index.ts: -------------------------------------------------------------------------------- 1 | import patch1 from './patch-1.js'; 2 | import patch2 from './patch-2.js'; 3 | 4 | export default [patch1, patch2] as const; 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "." 3 | - "examples/*" 4 | 5 | catalog: 6 | next: "16.0.10" 7 | react: "19.1.1" 8 | react-dom: "19.1.1" 9 | "@types/react": "19.1.10" 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome", 4 | "streetsidesoftware.code-spell-checker", 5 | "gruntfuggly.todo-tree", 6 | "ms-playwright.playwright" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /examples/base-path/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /examples/chat-room/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /examples/custom-server/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | export type { RouteContext } from './helpers/module.js'; 2 | export type { SocketHandler, UpgradeHandler } from './helpers/socket.js'; 3 | export { 4 | getHttpServer, 5 | getWebSocketServer, 6 | setHttpServer, 7 | setWebSocketServer, 8 | } from './persistent.js'; 9 | export * from './setup.js'; 10 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", 3 | "changelog": "@changesets/changelog-git", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { defineCommandGroup } from './helpers/define.js'; 2 | import patchCommand from './patch.js'; 3 | import verifyCommand from './verify.js'; 4 | 5 | export default defineCommandGroup({ 6 | name: 'next-ws', 7 | description: 'Patch the local Next.js installation to support WebSockets', 8 | children: [patchCommand, verifyCommand], 9 | }); 10 | -------------------------------------------------------------------------------- /examples/chat-room/app/(simple)/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; 4 | 5 | export default function Page() { 6 | const [messages, sendMessage] = useMessaging( 7 | () => `ws://${window.location.host}/api/ws`, 8 | ); 9 | 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/custom-server/app/(simple)/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; 4 | 5 | export default function Page() { 6 | const [messages, sendMessage] = useMessaging( 7 | () => `ws://${window.location.host}/api/ws`, 8 | ); 9 | 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/base-path/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: React.PropsWithChildren) { 2 | return ( 3 | 4 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /examples/chat-room/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: React.PropsWithChildren) { 2 | return ( 3 | 4 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /examples/base-path/app/(simple)/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; 4 | 5 | export default function Page() { 6 | const [messages, sendMessage] = useMessaging( 7 | () => `ws://${window.location.host}/some-base-path/api/ws`, 8 | ); 9 | 10 | return ( 11 |
12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/custom-server/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: React.PropsWithChildren) { 2 | return ( 3 | 4 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /.github/actions/node/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Node.js 2 | description: Setup Node.js, PNPM, and install dependencies 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - uses: pnpm/action-setup@v4 8 | 9 | - name: Setup Node.js environment 10 | uses: actions/setup-node@v3 11 | with: 12 | node-version: 20 13 | registry-url: "https://registry.npmjs.org" 14 | cache: pnpm 15 | 16 | - name: Install dependencies 17 | run: pnpm install 18 | shell: bash 19 | -------------------------------------------------------------------------------- /examples/base-path/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/base-path", 3 | "private": true, 4 | "scripts": { 5 | "build": "next build", 6 | "start": "next start", 7 | "dev": "next dev", 8 | "prepare": "next-ws patch" 9 | }, 10 | "dependencies": { 11 | "next": "catalog:", 12 | "next-ws": "workspace:^", 13 | "react": "catalog:", 14 | "react-dom": "catalog:", 15 | "shared": "workspace:^" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "catalog:" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/chat-room/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/chat-room", 3 | "private": true, 4 | "scripts": { 5 | "build": "next build", 6 | "start": "next start", 7 | "dev": "next dev", 8 | "prepare": "next-ws patch" 9 | }, 10 | "dependencies": { 11 | "next": "catalog:", 12 | "next-ws": "workspace:^", 13 | "react": "catalog:", 14 | "react-dom": "catalog:", 15 | "shared": "workspace:^" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "catalog:" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/_shared/src/websocket.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useRef, useState } from 'react'; 4 | 5 | export function useWebSocket(url: () => string) { 6 | const ref = useRef(null); 7 | const target = useRef(url); 8 | const [, update] = useState(0); 9 | 10 | useEffect(() => { 11 | if (ref.current) return; 12 | const socket = new WebSocket(target.current()); 13 | Reflect.set(ref, 'current', socket); 14 | update((p) => p + 1); 15 | }, []); 16 | 17 | return ref.current; 18 | } 19 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['src/{client,server}/index.ts'], 6 | // Keep the extension as .cjs as to not break the require() calls in the patches 7 | outExtension: () => ({ js: '.cjs', dts: '.d.cts' }), 8 | format: 'cjs', 9 | dts: true, 10 | }, 11 | { 12 | entry: ['src/cli.ts'], 13 | outExtension: () => ({ js: '.cjs' }), 14 | format: 'cjs', 15 | external: ['next-ws'], 16 | noExternal: ['*'], 17 | }, 18 | ]); 19 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-triage.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Triage 2 | 3 | on: 4 | - pull_request_target 5 | 6 | jobs: 7 | label: 8 | name: Label Pull Request 9 | permissions: 10 | contents: read 11 | pull-requests: write 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Apply pull request labels 19 | uses: actions/labeler@v5 20 | with: 21 | configuration-path: .github/pr-labels.yml 22 | -------------------------------------------------------------------------------- /.github/workflows/label-sync.yml: -------------------------------------------------------------------------------- 1 | name: Label Sync 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | paths: [.github/labels.yml] 8 | 9 | jobs: 10 | sync: 11 | name: Label Sync 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | issues: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Sync labels 22 | uses: crazy-max/ghaction-github-labeler@v5 23 | with: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /examples/_shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "private": true, 4 | "exports": { 5 | ".": { 6 | "types": "./dist/index.d.ts", 7 | "default": "./dist/index.js" 8 | }, 9 | "./chat-room": { 10 | "types": "./dist/chat-room/index.d.ts", 11 | "default": "./dist/chat-room/index.js" 12 | } 13 | }, 14 | "scripts": { 15 | "build": "tsc", 16 | "postinstall": "pnpm build" 17 | }, 18 | "dependencies": { 19 | "react": "catalog:" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "catalog:" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/chat-room/app/(dynamic)/[code]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useParams } from 'next/navigation'; 4 | import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; 5 | 6 | export default function Page() { 7 | const { code } = useParams(); 8 | const [messages, sendMessage] = useMessaging( 9 | () => `ws://${window.location.host}/${code}/api/ws`, 10 | ); 11 | 12 | return ( 13 |
14 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /examples/custom-server/app/(dynamic)/[code]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useParams } from 'next/navigation'; 4 | import { MessageList, MessageSubmit, useMessaging } from 'shared/chat-room'; 5 | 6 | export default function Page() { 7 | const { code } = useParams(); 8 | const [messages, sendMessage] = useMessaging( 9 | () => `ws://${window.location.host}/${code}/api/ws`, 10 | ); 11 | 12 | return ( 13 |
14 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /examples/custom-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/custom-server", 3 | "private": true, 4 | "scripts": { 5 | "build": "next build", 6 | "start": "NODE_ENV=production tsx --require=\"./global.js\" server.ts", 7 | "dev": "tsx --require=\"./global.js\" server.ts", 8 | "prepare": "next-ws patch" 9 | }, 10 | "dependencies": { 11 | "next": "catalog:", 12 | "next-ws": "workspace:^", 13 | "react": "catalog:", 14 | "react-dom": "catalog:", 15 | "shared": "workspace:^" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "catalog:", 19 | "tsx": "^4.20.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/pr-labels.yml: -------------------------------------------------------------------------------- 1 | "dependencies": 2 | - changed-files: 3 | - any-glob-to-any-file: ["pnpm-lock.yaml"] 4 | 5 | "client": 6 | - changed-files: 7 | - any-glob-to-any-file: ["src/client/**"] 8 | 9 | "server": 10 | - changed-files: 11 | - any-glob-to-any-file: ["src/server/**"] 12 | 13 | "patches": 14 | - changed-files: 15 | - any-glob-to-any-file: ["src/patches/**"] 16 | 17 | "cli": 18 | - changed-files: 19 | - any-glob-to-any-file: ["src/cli.ts", "src/commands/**"] 20 | 21 | "examples": 22 | - changed-files: 23 | - any-glob-to-any-file: ["examples/**"] 24 | 25 | "tests": 26 | - changed-files: 27 | - any-glob-to-any-file: ["tests/**"] 28 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | workers: process.env.CI ? 1 : undefined, 5 | testDir: 'tests', 6 | reporter: [['html', { outputFolder: 'tests/.report' }]], 7 | retries: 1, 8 | use: { trace: 'on-first-retry' }, 9 | outputDir: 'tests/.results', 10 | webServer: [ 11 | { 12 | name: 'custom-server', 13 | cwd: 'examples/custom-server', 14 | command: 'npm run dev', 15 | env: { PORT: '3003' }, 16 | port: 3003, 17 | reuseExistingServer: !process.env.CI, 18 | }, 19 | { 20 | name: 'chat-room', 21 | cwd: 'examples/chat-room', 22 | command: 'npm run dev -- --port 3001', 23 | port: 3001, 24 | reuseExistingServer: !process.env.CI, 25 | }, 26 | { 27 | name: 'base-path', 28 | cwd: 'examples/base-path', 29 | command: 'npm run dev -- --port 3002', 30 | port: 3002, 31 | reuseExistingServer: !process.env.CI, 32 | }, 33 | ], 34 | }); 35 | -------------------------------------------------------------------------------- /src/patches/patch-2.ts: -------------------------------------------------------------------------------- 1 | import { definePatch, definePatchStep } from './helpers/define.js'; 2 | import { 3 | patchCookies as p1_patchCookies, 4 | patchHeaders as p1_patchHeaders, 5 | patchNextNodeServer as p1_patchNextNodeServer, 6 | patchNextTypesPlugin as p1_patchNextTypesPlugin, 7 | patchRouterServer as p1_patchRouterServer, 8 | } from './patch-1.js'; 9 | 10 | export const patchHeaders = definePatchStep({ 11 | ...p1_patchHeaders, 12 | // CHANGE(next@15): headers function was moved 13 | path: 'next:dist/server/request/headers.js', 14 | }); 15 | 16 | export const patchCookies = definePatchStep({ 17 | ...p1_patchCookies, 18 | // CHANGE(next@15): cookies function was moved 19 | path: 'next:dist/server/request/cookies.js', 20 | }); 21 | 22 | export default definePatch({ 23 | name: 'patch-2', 24 | versions: '>=15.0.0 <=16.0.10', 25 | steps: [ 26 | p1_patchNextNodeServer, 27 | p1_patchRouterServer, 28 | p1_patchNextTypesPlugin, 29 | patchHeaders, 30 | patchCookies, 31 | ], 32 | }); 33 | -------------------------------------------------------------------------------- /src/server/helpers/socket.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WebSocket socket handler. 3 | * @param client WebSocket client instance 4 | * @param request Node.js HTTP incoming message instance 5 | * @param server WebSocket server instance 6 | * @param context Route context 7 | * @deprecated Prefer UPGRADE and {@link UpgradeHandler} 8 | */ 9 | export type SocketHandler = ( 10 | client: import('ws').WebSocket, 11 | request: import('http').IncomingMessage, 12 | server: import('ws').WebSocketServer, 13 | context: { params: Record }, 14 | ) => unknown; 15 | 16 | /** 17 | * WebSocket upgrade handler. 18 | * @param client WebSocket client instance 19 | * @param server WebSocket server instance 20 | * @param request Next.js request instance 21 | * @param context Route context 22 | */ 23 | export type UpgradeHandler = ( 24 | client: import('ws').WebSocket, 25 | server: import('ws').WebSocketServer, 26 | request: import('next/server').NextRequest, 27 | context: import('./module.js').RouteContext, 28 | ) => unknown; 29 | -------------------------------------------------------------------------------- /examples/custom-server/server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'node:http'; 2 | import { parse } from 'node:url'; 3 | import next from 'next'; 4 | import { setHttpServer, setWebSocketServer } from 'next-ws/server'; 5 | import { WebSocketServer } from 'ws'; 6 | 7 | const httpServer = new Server(); 8 | setHttpServer(httpServer); 9 | const webSocketServer = new WebSocketServer({ noServer: true }); 10 | setWebSocketServer(webSocketServer); 11 | 12 | const dev = process.env.NODE_ENV !== 'production'; 13 | const hostname = 'localhost'; 14 | const port = Number.parseInt(process.env.PORT ?? '3000', 10); 15 | const app = next({ dev, hostname, port, customServer: true }); 16 | const handle = app.getRequestHandler(); 17 | 18 | app.prepare().then(() => { 19 | httpServer 20 | .on('request', async (req, res) => { 21 | if (!req.url) return; 22 | const parsedUrl = parse(req.url, true); 23 | await handle(req, res, parsedUrl); 24 | }) 25 | .listen(port, () => { 26 | console.log(` ▲ Ready on http://${hostname}:${port}`); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "files": { 4 | "includes": [ 5 | "**", 6 | "!**/.ignore/**/*", 7 | "!**/node_modules/**/*", 8 | "!**/dist/**/*", 9 | "!**/.next/**/*" 10 | ] 11 | }, 12 | 13 | "formatter": { 14 | "enabled": true, 15 | "indentStyle": "space", 16 | "indentWidth": 2, 17 | "lineEnding": "lf", 18 | "expand": "auto" 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "correctness": { 25 | "useHookAtTopLevel": "off" 26 | }, 27 | "complexity": { 28 | "noUselessUndefinedInitialization": "off" 29 | } 30 | } 31 | }, 32 | "assist": { 33 | "actions": { 34 | "source": { 35 | "organizeImports": "on" 36 | } 37 | } 38 | }, 39 | 40 | "javascript": { 41 | "formatter": { 42 | "quoteStyle": "single", 43 | "jsxQuoteStyle": "double", 44 | "semicolons": "always" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/server/helpers/request.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'node:http'; 2 | import { NextRequest } from 'next/server'; 3 | 4 | /** 5 | * Convert a Node.js HTTP incoming message instance to a Next.js request instance. 6 | * @param message Node.js HTTP incoming message instance 7 | * @returns Next.js request instance 8 | */ 9 | export function toNextRequest(message: IncomingMessage) { 10 | const controller = new AbortController(); 11 | const headers = new Headers(message.headers as never); 12 | const protocol = 'encrypted' in message.socket ? 'https' : 'http'; 13 | const url = `${protocol}://${headers.get('host')}${message.url}`; 14 | 15 | message.once('aborted', () => controller.abort()); 16 | 17 | return new NextRequest(url, { 18 | method: message.method, 19 | headers: headers, 20 | body: 21 | message.method === 'GET' || message.method === 'HEAD' 22 | ? undefined 23 | : (message as unknown as ReadableStream), 24 | signal: controller.signal, 25 | referrer: headers.get('referer') || undefined, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /examples/_shared/src/chat-room/message-list.tsx: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/correctness/noUnusedImports: React is used in JSX output 2 | import * as React from 'react'; 3 | import type { Message } from './messaging'; 4 | 5 | export function MessageList({ messages }: { messages: Message[] }) { 6 | return ( 7 |
    16 | {messages.map((message, i) => ( 17 |
  • 18 | {message.author}: {message.content} 19 |
  • 20 | ))} 21 | 22 | {messages.length === 0 && ( 23 |
    35 |

    Waiting for messages...

    36 |
    37 | )} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /examples/_shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "exclude": ["node_modules"], 4 | 5 | "compilerOptions": { 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "exactOptionalPropertyTypes": false, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noPropertyAccessFromIndexSignature": false, 13 | "noUncheckedIndexedAccess": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "strict": true, 17 | 18 | "allowArbitraryExtensions": false, 19 | "allowImportingTsExtensions": false, 20 | "module": "ESNext", 21 | "moduleResolution": "Node", 22 | "resolveJsonModule": true, 23 | 24 | "declaration": true, 25 | "declarationMap": true, 26 | "importHelpers": false, 27 | "newLine": "lf", 28 | "noEmitHelpers": true, 29 | "removeComments": false, 30 | "sourceMap": true, 31 | 32 | "experimentalDecorators": true, 33 | "lib": ["DOM", "ESNext"], 34 | "target": "ES2022", 35 | "useDefineForClassFields": true, 36 | "skipLibCheck": true, 37 | 38 | "outDir": "./dist", 39 | "paths": { "~/*": ["./src/*"] }, 40 | "jsx": "react" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/helpers/semver.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'semver'; 2 | import Range from 'semver/classes/range.js'; 3 | import SemVer from 'semver/classes/semver.js'; 4 | import gt from 'semver/functions/gt.js'; 5 | 6 | /** 7 | * Get the maximum version from a range 8 | * @param range Range or string 9 | * @param loose Options or boolean 10 | * @returns Maximum version 11 | */ 12 | export function maxVersion(range: Range | string, loose?: Options | boolean) { 13 | range = new Range(range, loose); 14 | let maximumVersion: SemVer | null = null; 15 | 16 | for (const comparators of range.set) { 17 | for (const { 18 | operator, 19 | semver: { version: version_ }, 20 | } of comparators) { 21 | if (operator === '>' || operator === '>=') continue; 22 | 23 | const version = new SemVer(version_); 24 | 25 | if (operator === '<') { 26 | version.patch--; 27 | version.raw = version.format(); 28 | } 29 | 30 | if (!maximumVersion || gt(version, maximumVersion)) 31 | maximumVersion = version; 32 | } 33 | } 34 | 35 | return maximumVersion; 36 | } 37 | 38 | export { default as satisfies } from 'semver/functions/satisfies'; 39 | export { default as gtr } from 'semver/ranges/gtr'; 40 | export { default as ltr } from 'semver/ranges/ltr'; 41 | export { default as minVersion } from 'semver/ranges/min-version'; 42 | -------------------------------------------------------------------------------- /src/commands/verify.ts: -------------------------------------------------------------------------------- 1 | import { getInstalledNextVersion, readTrace } from '~/patches/helpers/next.js'; 2 | import * as console from './helpers/console.js'; 3 | import { defineCommand } from './helpers/define.js'; 4 | import patchCommand from './patch.js'; 5 | 6 | export default defineCommand({ 7 | name: 'verify', 8 | description: 'Verify that the local Next.js installation has been patched', 9 | options: [ 10 | { 11 | name: 'ensure', 12 | description: 'If not patched, then run the patch command', 13 | alias: 'e', 14 | }, 15 | ], 16 | async action(options) { 17 | const trace = await readTrace(); 18 | 19 | if (!trace) { 20 | if (options.ensure) { 21 | console.warn('Next.js has not been patched, running the patch command'); 22 | return patchCommand.action({ yes: true }); 23 | } else { 24 | console.error( 25 | "Next.js has not been patched, you'll need to run the patch command", 26 | ); 27 | process.exit(1); 28 | } 29 | } 30 | 31 | const current = await getInstalledNextVersion(); 32 | if (current !== trace.version) { 33 | console.error( 34 | "Next.js has been patched with a different version, you'll need to run the patch command", 35 | ); 36 | process.exit(1); 37 | } 38 | 39 | console.info('Next.js has been patched!'); 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Labels primarily for issues 2 | 3 | - name: bug 4 | description: Something is broken or not working as expected 5 | color: d73a4a 6 | 7 | - name: request 8 | description: A request for a new feature or a change in behaviour 9 | color: 0075ca 10 | 11 | # Labels primarily for pull requests 12 | 13 | - name: dependencies 14 | description: Updates or changes related to project dependencies 15 | color: ffb300 16 | 17 | - name: client 18 | description: Modifications or updates to client-handling code 19 | color: 039be5 20 | 21 | - name: server 22 | description: Modifications or updates to server-handling code 23 | color: fbc02d 24 | 25 | - name: patches 26 | description: Updates or fixes related to Next.js patches 27 | color: ff7043 28 | 29 | - name: cli 30 | description: Improvements or changes to the command-line interface 31 | color: 03a9f4 32 | 33 | - name: examples 34 | description: Updates or additions to example apps 35 | color: 009688 36 | 37 | - name: tests 38 | description: Modifications, additions, or fixes related to testing 39 | color: 00bfa5 40 | 41 | # Labels primarily for both issues and pull requests 42 | 43 | - name: blocked 44 | description: Progress is halted due to a dependency or required action 45 | color: d73a4a 46 | 47 | - name: duplicate 48 | description: This issue or pull request already exists 49 | color: cfd3d7 50 | -------------------------------------------------------------------------------- /src/server/helpers/store.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncLocalStorage } from 'node:async_hooks'; 2 | import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies'; 3 | 4 | /** 5 | * Readonly {@link Headers} implementation. 6 | */ 7 | class ReadonlyHeaders extends Headers { 8 | override append(): never { 9 | throw new Error('Headers are read-only'); 10 | } 11 | 12 | override set(): never { 13 | throw new Error('Headers are read-only'); 14 | } 15 | 16 | override delete(): never { 17 | throw new Error('Headers are read-only'); 18 | } 19 | } 20 | 21 | /** 22 | * Readonly {@link RequestCookies} implementation. 23 | */ 24 | class ReadonlyRequestsCookies extends RequestCookies { 25 | override set(): never { 26 | throw new Error('Cookies are read-only'); 27 | } 28 | 29 | override delete(): never { 30 | throw new Error('Cookies are read-only'); 31 | } 32 | } 33 | 34 | export interface RequestStore { 35 | readonly headers: ReadonlyHeaders; 36 | readonly cookies: ReadonlyRequestsCookies; 37 | } 38 | 39 | /** 40 | * Create a new request store. 41 | * @param request {@link Request} instance 42 | * @returns A {@link RequestStore} object 43 | */ 44 | export function createRequestStore(request: Request): RequestStore { 45 | return { 46 | headers: new ReadonlyHeaders(request.headers), 47 | cookies: new ReadonlyRequestsCookies(request.headers), 48 | }; 49 | } 50 | 51 | export type RequestStorage = AsyncLocalStorage; 52 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.compactFolders": true, 3 | "explorer.fileNesting.enabled": true, 4 | "explorer.fileNesting.patterns": { 5 | "package.json": "pnpm-lock.yaml, pnpm-workspace.yaml", 6 | "*.js": "$(capture).js.map, $(capture).d.ts, $(capture).d.ts.map, $(capture).cjs", 7 | "*.cjs": "$(capture).cjs.map, $(capture).d.cts, $(capture).d.cts.map" 8 | }, 9 | 10 | "editor.insertSpaces": true, 11 | "editor.tabSize": 2, 12 | 13 | "[typescript]": { 14 | "editor.formatOnSave": true, 15 | "editor.defaultFormatter": "biomejs.biome", 16 | "editor.codeActionsOnSave": { 17 | "source.organizeImports.biome": "explicit" 18 | } 19 | }, 20 | 21 | // TypeScript 22 | "typescript.preferences.importModuleSpecifierEnding": "js", 23 | 24 | // Code Spell Checker 25 | "cSpell.language": "en-AU", 26 | "cSpell.words": [], 27 | 28 | // Todo Tree 29 | "todo-tree.general.tags": ["=====", "TODO", "CHANGE"], 30 | "todo-tree.highlights.customHighlight": { 31 | "=====": { 32 | "type": "whole-line", 33 | "icon": "bookmark", 34 | "foreground": "#dadada", 35 | "gutterIcon": true, 36 | "hideFromTree": true 37 | }, 38 | "TODO": { 39 | "type": "whole-line", 40 | "icon": "inbox", 41 | "foreground": "#ff9900", 42 | "gutterIcon": true 43 | }, 44 | "CHANGE": { 45 | "type": "whole-line", 46 | "icon": "diff", 47 | "foreground": "#00ff00", 48 | "gutterIcon": true 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/_shared/src/chat-room/message-submit.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | // biome-ignore lint/style/useImportType: React is used in JSX output 4 | import * as React from 'react'; 5 | import { useCallback } from 'react'; 6 | import type { Message } from './messaging'; 7 | 8 | export function MessageSubmit({ 9 | onMessage, 10 | }: { 11 | onMessage(message: Message): void; 12 | }) { 13 | const handleSubmit = useCallback( 14 | (event: React.FormEvent) => { 15 | event.preventDefault(); 16 | const form = new FormData(event.currentTarget); 17 | const author = form.get('author') as string; 18 | const content = form.get('content') as string; 19 | if (!author || !content) return; 20 | 21 | onMessage({ author, content }); 22 | 23 | // Reset the content input (only) 24 | const contentInputElement = event.currentTarget // 25 | .querySelector('input[name="content"]'); 26 | if (contentInputElement) contentInputElement.value = ''; 27 | }, 28 | [onMessage], 29 | ); 30 | 31 | return ( 32 |
33 | 39 | 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig.json", 3 | 4 | "include": ["src/**/*", "tests/**/*"], 5 | "exclude": ["node_modules", "dist"], 6 | 7 | "compileOnSave": true, 8 | "compilerOptions": { 9 | "allowUnreachableCode": false, 10 | "allowUnusedLabels": false, 11 | "exactOptionalPropertyTypes": false, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitOverride": true, 14 | "noImplicitReturns": true, 15 | "noPropertyAccessFromIndexSignature": false, 16 | "noUncheckedIndexedAccess": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "strict": true, 20 | 21 | "allowArbitraryExtensions": false, 22 | "allowImportingTsExtensions": false, 23 | "module": "ESNext", 24 | "moduleResolution": "Bundler", 25 | "resolveJsonModule": true, 26 | "resolvePackageJsonExports": true, 27 | "resolvePackageJsonImports": true, 28 | 29 | "declaration": true, 30 | "declarationMap": true, 31 | "importHelpers": false, 32 | "newLine": "lf", 33 | "noEmitHelpers": true, 34 | "removeComments": false, 35 | "sourceMap": true, 36 | 37 | "allowSyntheticDefaultImports": true, 38 | "esModuleInterop": true, 39 | "forceConsistentCasingInFileNames": true, 40 | "isolatedModules": true, 41 | 42 | "experimentalDecorators": true, 43 | "lib": ["DOM", "ESNext"], 44 | "target": "ES2022", 45 | "useDefineForClassFields": true, 46 | "skipLibCheck": true, 47 | 48 | "outDir": "./dist", 49 | "paths": { "~/*": ["./src/*"] }, 50 | 51 | "jsx": "react" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/base-path/app/(simple)/api/ws/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | 3 | export function GET() { 4 | const headers = new Headers(); 5 | headers.set('Connection', 'Upgrade'); 6 | headers.set('Upgrade', 'websocket'); 7 | return new Response('Upgrade Required', { status: 426, headers }); 8 | } 9 | 10 | export async function UPGRADE( 11 | client: import('ws').WebSocket, 12 | server: import('ws').WebSocketServer, 13 | ) { 14 | // For testing purposes 15 | // TODO: Make a real world use case for this 16 | await headers(); 17 | 18 | for (const other of server.clients) { 19 | if (client === other || other.readyState !== other.OPEN) continue; 20 | other.send( 21 | JSON.stringify({ 22 | author: 'System', 23 | content: 'A new user joined the chat', 24 | }), 25 | ); 26 | } 27 | 28 | client.on('message', (message) => { 29 | // Forward the message to all other clients 30 | for (const other of server.clients) 31 | if (client !== other && other.readyState === other.OPEN) 32 | other.send(message); 33 | }); 34 | 35 | client.send( 36 | JSON.stringify({ 37 | author: 'System', 38 | content: `Welcome to the chat! There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, 39 | }), 40 | ); 41 | 42 | client.once('close', () => { 43 | for (const other of server.clients) { 44 | if (client === other || other.readyState !== other.OPEN) continue; 45 | other.send( 46 | JSON.stringify({ 47 | author: 'System', 48 | content: 'A user left the chat', 49 | }), 50 | ); 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /examples/chat-room/app/(simple)/api/ws/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | 3 | export function GET() { 4 | const headers = new Headers(); 5 | headers.set('Connection', 'Upgrade'); 6 | headers.set('Upgrade', 'websocket'); 7 | return new Response('Upgrade Required', { status: 426, headers }); 8 | } 9 | 10 | export async function UPGRADE( 11 | client: import('ws').WebSocket, 12 | server: import('ws').WebSocketServer, 13 | ) { 14 | // For testing purposes 15 | // TODO: Make a real world use case for this 16 | await headers(); 17 | 18 | for (const other of server.clients) { 19 | if (client === other || other.readyState !== other.OPEN) continue; 20 | other.send( 21 | JSON.stringify({ 22 | author: 'System', 23 | content: 'A new user joined the chat', 24 | }), 25 | ); 26 | } 27 | 28 | client.on('message', (message) => { 29 | // Forward the message to all other clients 30 | for (const other of server.clients) 31 | if (client !== other && other.readyState === other.OPEN) 32 | other.send(message); 33 | }); 34 | 35 | client.send( 36 | JSON.stringify({ 37 | author: 'System', 38 | content: `Welcome to the chat! There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, 39 | }), 40 | ); 41 | 42 | client.once('close', () => { 43 | for (const other of server.clients) { 44 | if (client === other || other.readyState !== other.OPEN) continue; 45 | other.send( 46 | JSON.stringify({ 47 | author: 'System', 48 | content: 'A user left the chat', 49 | }), 50 | ); 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /examples/custom-server/app/(simple)/api/ws/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | 3 | export function GET() { 4 | const headers = new Headers(); 5 | headers.set('Connection', 'Upgrade'); 6 | headers.set('Upgrade', 'websocket'); 7 | return new Response('Upgrade Required', { status: 426, headers }); 8 | } 9 | 10 | export async function UPGRADE( 11 | client: import('ws').WebSocket, 12 | server: import('ws').WebSocketServer, 13 | ) { 14 | // For testing purposes 15 | // TODO: Make a real world use case for this 16 | await headers(); 17 | 18 | for (const other of server.clients) { 19 | if (client === other || other.readyState !== other.OPEN) continue; 20 | other.send( 21 | JSON.stringify({ 22 | author: 'System', 23 | content: 'A new user joined the chat', 24 | }), 25 | ); 26 | } 27 | 28 | client.on('message', (message) => { 29 | // Forward the message to all other clients 30 | for (const other of server.clients) 31 | if (client !== other && other.readyState === other.OPEN) 32 | other.send(message); 33 | }); 34 | 35 | client.send( 36 | JSON.stringify({ 37 | author: 'System', 38 | content: `Welcome to the chat! There ${server.clients.size - 1 === 1 ? 'is 1 other user' : `are ${server.clients.size - 1 || 'no'} other users`} online`, 39 | }), 40 | ); 41 | 42 | client.once('close', () => { 43 | for (const other of server.clients) { 44 | if (client === other || other.readyState !== other.OPEN) continue; 45 | other.send( 46 | JSON.stringify({ 47 | author: 'System', 48 | content: 'A user left the chat', 49 | }), 50 | ); 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /examples/base-path/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig.json", 3 | 4 | "include": ["app/**/*", ".next/types/**/*.ts"], 5 | "exclude": ["node_modules", ".next"], 6 | 7 | "compileOnSave": true, 8 | "compilerOptions": { 9 | "allowUnreachableCode": false, 10 | "allowUnusedLabels": false, 11 | "exactOptionalPropertyTypes": false, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitOverride": true, 14 | "noImplicitReturns": true, 15 | "noPropertyAccessFromIndexSignature": false, 16 | "noUncheckedIndexedAccess": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "strict": true, 20 | 21 | "allowArbitraryExtensions": false, 22 | "allowImportingTsExtensions": false, 23 | "allowJs": true, 24 | "module": "ESNext", 25 | "moduleResolution": "Bundler", 26 | "resolveJsonModule": true, 27 | "resolvePackageJsonExports": true, 28 | "resolvePackageJsonImports": true, 29 | 30 | "declaration": true, 31 | "declarationMap": true, 32 | "importHelpers": false, 33 | "newLine": "lf", 34 | "noEmit": true, 35 | "noEmitHelpers": true, 36 | "removeComments": false, 37 | "sourceMap": true, 38 | "allowSyntheticDefaultImports": true, 39 | "esModuleInterop": true, 40 | "forceConsistentCasingInFileNames": true, 41 | "isolatedModules": true, 42 | 43 | "experimentalDecorators": true, 44 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 45 | "target": "ES2022", 46 | "useDefineForClassFields": true, 47 | "skipLibCheck": true, 48 | 49 | "jsx": "preserve", 50 | "outDir": "./dist", 51 | "paths": { "~/*": ["./src/*"] }, 52 | 53 | "plugins": [{ "name": "next" }], 54 | "incremental": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/chat-room/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig.json", 3 | 4 | "include": ["app/**/*", ".next/types/**/*.ts"], 5 | "exclude": ["node_modules", ".next"], 6 | 7 | "compileOnSave": true, 8 | "compilerOptions": { 9 | "allowUnreachableCode": false, 10 | "allowUnusedLabels": false, 11 | "exactOptionalPropertyTypes": false, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitOverride": true, 14 | "noImplicitReturns": true, 15 | "noPropertyAccessFromIndexSignature": false, 16 | "noUncheckedIndexedAccess": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "strict": true, 20 | 21 | "allowArbitraryExtensions": false, 22 | "allowImportingTsExtensions": false, 23 | "allowJs": true, 24 | "module": "ESNext", 25 | "moduleResolution": "Bundler", 26 | "resolveJsonModule": true, 27 | "resolvePackageJsonExports": true, 28 | "resolvePackageJsonImports": true, 29 | 30 | "declaration": true, 31 | "declarationMap": true, 32 | "importHelpers": false, 33 | "newLine": "lf", 34 | "noEmit": true, 35 | "noEmitHelpers": true, 36 | "removeComments": false, 37 | "sourceMap": true, 38 | "allowSyntheticDefaultImports": true, 39 | "esModuleInterop": true, 40 | "forceConsistentCasingInFileNames": true, 41 | "isolatedModules": true, 42 | 43 | "experimentalDecorators": true, 44 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 45 | "target": "ES2022", 46 | "useDefineForClassFields": true, 47 | "skipLibCheck": true, 48 | 49 | "jsx": "preserve", 50 | "outDir": "./dist", 51 | "paths": { "~/*": ["./src/*"] }, 52 | 53 | "plugins": [{ "name": "next" }], 54 | "incremental": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/custom-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig.json", 3 | 4 | "include": ["app/**/*", ".next/types/**/*.ts"], 5 | "exclude": ["node_modules", ".next"], 6 | 7 | "compileOnSave": true, 8 | "compilerOptions": { 9 | "allowUnreachableCode": false, 10 | "allowUnusedLabels": false, 11 | "exactOptionalPropertyTypes": false, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitOverride": true, 14 | "noImplicitReturns": true, 15 | "noPropertyAccessFromIndexSignature": false, 16 | "noUncheckedIndexedAccess": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "strict": true, 20 | 21 | "allowArbitraryExtensions": false, 22 | "allowImportingTsExtensions": false, 23 | "allowJs": true, 24 | "module": "ESNext", 25 | "moduleResolution": "Bundler", 26 | "resolveJsonModule": true, 27 | "resolvePackageJsonExports": true, 28 | "resolvePackageJsonImports": true, 29 | 30 | "declaration": true, 31 | "declarationMap": true, 32 | "importHelpers": false, 33 | "newLine": "lf", 34 | "noEmit": true, 35 | "noEmitHelpers": true, 36 | "removeComments": false, 37 | "sourceMap": true, 38 | "allowSyntheticDefaultImports": true, 39 | "esModuleInterop": true, 40 | "forceConsistentCasingInFileNames": true, 41 | "isolatedModules": true, 42 | 43 | "experimentalDecorators": true, 44 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 45 | "target": "ES2022", 46 | "useDefineForClassFields": true, 47 | "skipLibCheck": true, 48 | 49 | "jsx": "preserve", 50 | "outDir": "./dist", 51 | "paths": { "~/*": ["./src/*"] }, 52 | 53 | "plugins": [{ "name": "next" }], 54 | "incremental": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/patches/helpers/next.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'node:fs/promises'; 2 | import { dirname, join } from 'node:path'; 3 | 4 | /** 5 | * Resolve the path to the next-ws installation directory. 6 | * @returns The installation directory for this package 7 | */ 8 | export function resolveNextWsDirectory() { 9 | const id = // 10 | require.resolve('next-ws/package.json', { paths: [process.cwd()] }); 11 | return dirname(id); 12 | } 13 | 14 | /** 15 | * Resolve the path to the Next.js installation directory. 16 | * @returns The Next.js installation directory 17 | */ 18 | export function resolveNextDirectory() { 19 | const id = // 20 | require.resolve('next/package.json', { paths: [process.cwd()] }); 21 | return dirname(id); 22 | } 23 | 24 | /** 25 | * Get the version of Next.js from the installation directory's `package.json`. 26 | * @returns The version of Next.js 27 | */ 28 | export async function getInstalledNextVersion() { 29 | const id = join(resolveNextDirectory(), 'package.json'); 30 | const pkg = await readFile(id, 'utf8').then(JSON.parse); 31 | return String(pkg.version.split('-')[0]); 32 | } 33 | 34 | export interface Trace { 35 | patch: string; 36 | version: string; 37 | } 38 | 39 | /** 40 | * Get the next-ws trace of the current installation of Next.js. 41 | * @returns Trace object 42 | */ 43 | export async function readTrace() { 44 | const id = join(resolveNextDirectory(), '.next-ws-trace.json'); 45 | return readFile(id, 'utf-8') 46 | .then(JSON.parse) 47 | .catch(() => null); 48 | } 49 | 50 | /** 51 | * Set the next-ws trace of the current installation of Next.js. 52 | * @param trace Trace object 53 | */ 54 | export async function writeTrace(trace: Trace) { 55 | const id = join(resolveNextDirectory(), '.next-ws-trace.json'); 56 | await writeFile(id, JSON.stringify(trace, null, 2)); 57 | } 58 | -------------------------------------------------------------------------------- /examples/chat-room/app/(dynamic)/[code]/api/ws/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | 3 | export function GET() { 4 | const headers = new Headers(); 5 | headers.set('Connection', 'Upgrade'); 6 | headers.set('Upgrade', 'websocket'); 7 | return new Response('Upgrade Required', { status: 426, headers }); 8 | } 9 | 10 | export async function UPGRADE( 11 | client: import('ws').WebSocket, 12 | server: import('ws').WebSocketServer, 13 | _request: import('next/server').NextRequest, 14 | context: import('next-ws/server').RouteContext<'/[code]/api/ws'>, 15 | ) { 16 | // For testing purposes 17 | // TODO: Make a real world use case for this 18 | await headers(); 19 | 20 | const { code } = context.params; 21 | 22 | for (const other of server.clients) { 23 | if (client === other || other.readyState !== other.OPEN) continue; 24 | other.send( 25 | JSON.stringify({ 26 | author: 'System', 27 | content: `A new user joined the ${code} chat.`, 28 | }), 29 | ); 30 | } 31 | 32 | client.on('message', (message) => { 33 | // Forward the message to all other clients 34 | for (const other of server.clients) 35 | if (client !== other && other.readyState === other.OPEN) 36 | other.send(message); 37 | }); 38 | 39 | client.send( 40 | JSON.stringify({ 41 | author: 'System', 42 | 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`, 43 | }), 44 | ); 45 | 46 | client.once('close', () => { 47 | for (const other of server.clients) { 48 | if (client === other || other.readyState !== other.OPEN) continue; 49 | other.send( 50 | JSON.stringify({ 51 | author: 'System', 52 | content: 'A user left the chat', 53 | }), 54 | ); 55 | } 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /examples/custom-server/app/(dynamic)/[code]/api/ws/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | 3 | export function GET() { 4 | const headers = new Headers(); 5 | headers.set('Connection', 'Upgrade'); 6 | headers.set('Upgrade', 'websocket'); 7 | return new Response('Upgrade Required', { status: 426, headers }); 8 | } 9 | 10 | export async function UPGRADE( 11 | client: import('ws').WebSocket, 12 | server: import('ws').WebSocketServer, 13 | _request: import('next/server').NextRequest, 14 | context: import('next-ws/server').RouteContext<'/[code]/api/ws'>, 15 | ) { 16 | // For testing purposes 17 | // TODO: Make a real world use case for this 18 | await headers(); 19 | 20 | const { code } = context.params; 21 | 22 | for (const other of server.clients) { 23 | if (client === other || other.readyState !== other.OPEN) continue; 24 | other.send( 25 | JSON.stringify({ 26 | author: 'System', 27 | content: `A new user joined the ${code} chat.`, 28 | }), 29 | ); 30 | } 31 | 32 | client.on('message', (message) => { 33 | // Forward the message to all other clients 34 | for (const other of server.clients) 35 | if (client !== other && other.readyState === other.OPEN) 36 | other.send(message); 37 | }); 38 | 39 | client.send( 40 | JSON.stringify({ 41 | author: 'System', 42 | 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`, 43 | }), 44 | ); 45 | 46 | client.once('close', () => { 47 | for (const other of server.clients) { 48 | if (client === other || other.readyState !== other.OPEN) continue; 49 | other.send( 50 | JSON.stringify({ 51 | author: 'System', 52 | content: 'A user left the chat', 53 | }), 54 | ); 55 | } 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /examples/_shared/src/chat-room/messaging.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import { useWebSocket } from '../websocket'; 5 | 6 | export interface Message { 7 | author: string; 8 | content: string; 9 | } 10 | 11 | export function useMessaging(url: () => string) { 12 | const socket = useWebSocket(url); 13 | 14 | const [messages, setMessages] = useState([]); 15 | 16 | useEffect(() => { 17 | const controller = new AbortController(); 18 | 19 | socket?.addEventListener( 20 | 'message', 21 | async (event) => { 22 | const payload = 23 | typeof event.data === 'string' ? event.data : await event.data.text(); 24 | const message = JSON.parse(payload) as Message; 25 | console.log('Incoming message:', message); 26 | setMessages((p) => [...p, message]); 27 | }, 28 | controller, 29 | ); 30 | 31 | socket?.addEventListener( 32 | 'error', 33 | () => { 34 | const content = 'An error occurred while connecting to the server'; 35 | setMessages((p) => [...p, { author: 'System', content }]); 36 | }, 37 | controller, 38 | ); 39 | 40 | socket?.addEventListener( 41 | 'close', 42 | (event) => { 43 | if (event.wasClean) return; 44 | const content = 'The connection to the server was closed unexpectedly'; 45 | setMessages((p) => [...p, { author: 'System', content }]); 46 | }, 47 | controller, 48 | ); 49 | 50 | return () => controller.abort(); 51 | }, [socket]); 52 | 53 | const sendMessage = useCallback( 54 | (message: Message) => { 55 | if (!socket || socket.readyState !== socket.OPEN) return; 56 | console.log('Outgoing message:', message); 57 | socket.send(JSON.stringify(message)); 58 | setMessages((p) => [...p, { ...message, author: 'You' }]); 59 | }, 60 | [socket], 61 | ); 62 | 63 | return [messages, sendMessage] as const; 64 | } 65 | -------------------------------------------------------------------------------- /tests/base-path.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page, test } from '@playwright/test'; 2 | 3 | let page1: Page, page2: Page; 4 | test.beforeEach(async ({ browser }) => { 5 | page1 = await browser.newPage(); 6 | page2 = await browser.newPage(); 7 | }); 8 | test.afterEach(async () => { 9 | await page1.close(); 10 | await page2.close(); 11 | }); 12 | 13 | test.use({ baseURL: 'http://localhost:3002' }); 14 | 15 | test.describe('Chat Room', () => { 16 | test('a user joins the chat and receives a welcome message', async () => { 17 | await page1.goto('/some-base-path'); 18 | 19 | const welcome1 = await page1.textContent('li:first-child'); 20 | expect(welcome1).toContain('Welcome to the chat!'); 21 | expect(welcome1).toContain('There are no other users online'); 22 | 23 | await page2.goto('/some-base-path'); 24 | 25 | const welcome2 = await page2.textContent('li:first-child'); 26 | expect(welcome2).toContain('Welcome to the chat!'); 27 | expect(welcome2).toContain('There is 1 other user online'); 28 | }); 29 | 30 | test('a new user joins the chat and all users receive a message', async () => { 31 | await page1.goto('/some-base-path'); 32 | await page2.goto('/some-base-path'); 33 | 34 | await page1.waitForTimeout(1000); // Can take a moment 35 | const message1 = await page1.textContent('li:last-child'); 36 | expect(message1).toContain('A new user joined the chat'); 37 | }); 38 | 39 | test('a user sends a message and all users receive it', async () => { 40 | await page1.goto('/some-base-path'); 41 | await page2.goto('/some-base-path'); 42 | 43 | await page1.fill('input[name=author]', 'Alice'); 44 | await page1.fill('input[name=content]', 'Hello, world!'); 45 | await page1.click('button[type=submit]'); 46 | 47 | const message1 = await page1.textContent('li:last-child'); 48 | expect(message1).toContain('You'); 49 | expect(message1).toContain('Hello, world!'); 50 | 51 | const message2 = await page2.textContent('li:last-child'); 52 | expect(message2).toContain('Alice'); 53 | expect(message2).toContain('Hello, world!'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/client/context.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | // biome-ignore lint/style/useImportType: Actually need the value for JSX 4 | import React, { createContext, useContext, useEffect, useRef } from 'react'; 5 | 6 | export const WebSocketContext = createContext(null); 7 | WebSocketContext.displayName = 'WebSocketContext'; 8 | export const WebSocketConsumer = WebSocketContext.Consumer; 9 | 10 | /** 11 | * Provides a WebSocket client to its children via context, 12 | * allowing for easy access to the WebSocket from anywhere in the app. 13 | * @param props WebSocket parameters and children. 14 | * @returns JSX Element 15 | * @deprecated `WebSocketProvider` is deprecated, use your own implementation instead. 16 | */ 17 | export function WebSocketProvider( 18 | p: React.PropsWithChildren<{ 19 | /** The URL for the WebSocket to connect to. */ 20 | url: string; 21 | /** The subprotocols to use. */ 22 | protocols?: string[] | string; 23 | /** The binary type to use. */ 24 | binaryType?: BinaryType; 25 | }>, 26 | ) { 27 | const clientRef = useRef(null); 28 | useEffect(() => { 29 | if (typeof window === 'undefined') return; 30 | 31 | if (clientRef.current) { 32 | clientRef.current.close(); 33 | clientRef.current = null; 34 | } 35 | 36 | const client = new WebSocket(p.url, p.protocols); 37 | if (p.binaryType) client.binaryType = p.binaryType; 38 | clientRef.current = client; 39 | 40 | return () => { 41 | client.close(); 42 | clientRef.current = null; 43 | }; 44 | }, [p.url, p.protocols, p.binaryType]); 45 | 46 | return ( 47 | 48 | {p.children} 49 | 50 | ); 51 | } 52 | 53 | /** 54 | * Access the websocket from anywhere in the app, so long as it's wrapped in a WebSocketProvider. 55 | * @returns WebSocket client when connected, null when disconnected. 56 | * @deprecated `useWebSocket` is deprecated, use your own implementation instead. 57 | */ 58 | export function useWebSocket() { 59 | const context = useContext(WebSocketContext); 60 | if (context === undefined) 61 | throw new Error('useWebSocket must be used within a WebSocketProvider'); 62 | return context; 63 | } 64 | -------------------------------------------------------------------------------- /src/patches/helpers/define.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'node:fs/promises'; 2 | import { resolve } from 'node:path'; 3 | import * as console from '~/commands/helpers/console.js'; 4 | import { resolveNextDirectory } from './next.js'; 5 | 6 | export interface PatchDefinition { 7 | name: string; 8 | // Due to the auto update github action, the typing of this needs to be strict 9 | versions: `>=${string}.${string}.${string} <=${string}.${string}.${string}`; 10 | steps: PatchStep[]; 11 | } 12 | 13 | export interface Patch extends PatchDefinition { 14 | execute(): Promise; 15 | } 16 | 17 | /** 18 | * Define a patch. 19 | * @param definition The definition for the patch 20 | * @returns The patch 21 | */ 22 | export function definePatch(definition: PatchDefinition): Patch { 23 | return { 24 | ...definition, 25 | async execute() { 26 | for (const step of this.steps) 27 | await console.task(step.execute(), step.title); 28 | }, 29 | }; 30 | } 31 | 32 | export interface PatchStepDefinition { 33 | title: string; 34 | path: `next:${string}` | (string & {}); 35 | transform(code: string): Promise; 36 | } 37 | 38 | export interface PatchStep extends PatchStepDefinition { 39 | execute(): Promise; 40 | } 41 | 42 | /** 43 | * Define a step for a patch. 44 | * @param definition The definition for the step 45 | * @returns The step 46 | */ 47 | export function definePatchStep(definition: PatchStepDefinition): PatchStep { 48 | return { 49 | ...definition, 50 | get path() { 51 | return resolvePath(definition.path); 52 | }, 53 | async execute() { 54 | console.debug(`Applying '${this.title}' to '${this.path}'`); 55 | const code = await readFile(this.path, 'utf8') // 56 | .then((code) => this.transform(code)); 57 | await writeFile(this.path, code); 58 | }, 59 | }; 60 | } 61 | 62 | /** 63 | * Patches allow prepending short-hands to paths, this will resolve them. 64 | * @param path The path to resolve 65 | * @returns The resolved path 66 | */ 67 | function resolvePath(path: string) { 68 | switch (path.split(':')[0]) { 69 | case 'next': { 70 | const nextDirectory = resolveNextDirectory(); 71 | const realPath = path.slice(5); 72 | return resolve(nextDirectory, realPath); 73 | } 74 | default: { 75 | return path; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/commands/patch.ts: -------------------------------------------------------------------------------- 1 | import { getInstalledNextVersion, writeTrace } from '~/patches/helpers/next.js'; 2 | import patches from '~/patches/index.js'; 3 | import * as console from './helpers/console.js'; 4 | import { defineCommand } from './helpers/define.js'; 5 | import * as semver from './helpers/semver.js'; 6 | 7 | export default defineCommand({ 8 | name: 'patch', 9 | description: 'Patch the local Next.js installation to support WebSockets', 10 | options: [ 11 | { 12 | name: 'yes', 13 | description: 'Skip confirmation prompt for unsupported versions', 14 | alias: 'y', 15 | }, 16 | ], 17 | async action(options) { 18 | const supported = patches.map((p) => p.versions).join(' || '); 19 | const minimum = semver.minVersion(supported)?.version ?? supported; 20 | const maximum = semver.maxVersion(supported)?.version ?? supported; 21 | const current = await getInstalledNextVersion(); 22 | 23 | if (semver.ltr(current, minimum)) { 24 | // The installed version is lower than the minimum supported version 25 | console.error( 26 | `Next.js v${current} is not supported, a minimum of v${minimum} is required`, 27 | ); 28 | process.exit(1); 29 | } 30 | 31 | let patch = patches.find((p) => semver.satisfies(current, p.versions)); 32 | if (semver.gtr(current, maximum)) { 33 | // The installed version is higher than the maximum supported version 34 | console.warn( 35 | `Next.js v${current} is not officially supported, a maximum of v${maximum} is recommended.`, 36 | ); 37 | const confirm = 38 | options.yes || 39 | (await console.confirm('Are you sure you want to proceed?')); 40 | 41 | if (confirm) { 42 | patch = patches[patches.length - 1]; 43 | console.info('Proceeding with the latest patch'); 44 | console.log( 45 | 'If you encounter any issues please report them at https://github.com/apteryxxyz/next-ws/issues', 46 | ); 47 | } else { 48 | console.error('Aborted'); 49 | process.exit(1); 50 | } 51 | } 52 | 53 | if (!patch) { 54 | console.error( 55 | `Next.js v${current} is not supported, please upgrade to a version within the range '${supported}'`, 56 | ); 57 | process.exit(1); 58 | } 59 | 60 | console.info(`Patching Next.js v${current} with '${patch.versions}'`); 61 | await patch.execute(); 62 | 63 | console.info('Saving patch trace file...'); 64 | await writeTrace({ patch: patch.versions, version: current }); 65 | 66 | console.info('All done!'); 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-ws", 3 | "version": "2.1.10", 4 | "packageManager": "pnpm@10.15.0", 5 | "description": "Add support for WebSockets in the Next.js app directory", 6 | "license": "MIT", 7 | "keywords": ["next", "websocket", "ws", "server", "client"], 8 | "homepage": "https://github.com/apteryxxyz/next-ws#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/apteryxxyz/next-ws.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/apteryxxyz/next-ws/issues" 15 | }, 16 | "bin": { 17 | "next-ws": "./dist/cli.cjs" 18 | }, 19 | "files": ["dist"], 20 | "exports": { 21 | "./client": { 22 | "types": "./dist/client/index.d.ts", 23 | "default": "./dist/client/index.cjs" 24 | }, 25 | "./server": { 26 | "types": "./dist/server/index.d.ts", 27 | "default": "./dist/server/index.cjs" 28 | }, 29 | "./package.json": "./package.json" 30 | }, 31 | "scripts": { 32 | "check": "tsc --noEmit", 33 | "lint": "biome ci .", 34 | "format": "biome check . --write", 35 | "build": "tsup", 36 | "dev": "tsup --watch", 37 | "test": "playwright test", 38 | "prepare": "husky", 39 | "postinstall": "biome format package.json --write && pnpm build", 40 | "prepack": "pinst --disable && biome format package.json --write", 41 | "postpack": "pinst --enable && biome format package.json --write", 42 | "change": "changeset", 43 | "release:version": "changeset version && biome format package.json --write", 44 | "release:snapshot:version": "changeset version --snapshot beta && biome format package.json --write", 45 | "release:publish": "pnpm build && changeset publish", 46 | "release:snapshot:publish": "pnpm build && changeset publish --tag beta --no-git-tag" 47 | }, 48 | "dependencies": { 49 | "jscodeshift": "^17.3.0", 50 | "minimist": "^1.2.8", 51 | "semver": "^7.7.2" 52 | }, 53 | "peerDependencies": { 54 | "next": ">=13.5.1", 55 | "react": "*", 56 | "ws": "*" 57 | }, 58 | "devDependencies": { 59 | "@biomejs/biome": "^2.2.0", 60 | "@changesets/changelog-git": "^0.2.1", 61 | "@changesets/cli": "^2.27.12", 62 | "@favware/npm-deprecate": "^2.0.0", 63 | "@playwright/test": "^1.55.0", 64 | "@types/jscodeshift": "^17.3.0", 65 | "@types/minimist": "^1.2.5", 66 | "@types/node": "^24.3.0", 67 | "@types/react": "catalog:", 68 | "@types/semver": "^7.7.0", 69 | "@types/ws": "^8.18.1", 70 | "chalk": "^5.6.0", 71 | "husky": "^9.1.7", 72 | "next": "catalog:", 73 | "pinst": "^3.0.0", 74 | "react": "catalog:", 75 | "react-dom": "catalog:", 76 | "tsup": "^8.5.0", 77 | "typescript": "^5.9.2" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/server/helpers/match.ts: -------------------------------------------------------------------------------- 1 | import type NextNodeServer from 'next/dist/server/next-server.js'; 2 | 3 | /** 4 | * Compiles a route pattern into a regular expression. 5 | * @param routePattern Route pattern, generated by Next.js 6 | * @returns Regular expression 7 | */ 8 | function compileRoutePattern(routePattern: string) { 9 | const escapedPattern = routePattern.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); 10 | const paramRegex = escapedPattern 11 | .replace(/\\\[\\\[(?:\\\.){3}([a-z0-9_]+)\\\]\\\]/gi, '?(?.+)?') // [[...param]] 12 | .replace(/\\\[(?:\\\.){3}([a-z0-9_]+)\\\]/gi, '(?.+)') // [...param] 13 | .replace(/\\\[([a-z0-9_]+)\\\]/gi, '(?<$1>[^/]+)'); // [param] 14 | return new RegExp(`^${paramRegex}$`); 15 | } 16 | 17 | /** 18 | * Extract the parameters from a request pathname given a route pattern. 19 | * @param routePattern Route pattern, generated by Next.js 20 | * @param requestPathname Request pathname 21 | * @returns Object containing the parameters 22 | */ 23 | function getRouteParams(routePattern: string, requestPathname: string) { 24 | const routeRegex = compileRoutePattern(routePattern); 25 | const match = requestPathname.replace(/\/+$/, '').match(routeRegex); 26 | if (!match) return null; 27 | if (!match.groups) return {}; 28 | 29 | const params: Record = {}; 30 | for (let [k, v] of Object.entries(match.groups)) { 31 | if (k.startsWith('r_')) { 32 | const optional = k.startsWith('r_o_'); 33 | k = k.slice(optional ? 4 : 2); 34 | v = v?.split('/') as never; 35 | } 36 | if (v) Reflect.set(params, k, v); 37 | } 38 | return params; 39 | } 40 | 41 | /** 42 | * Find the matching route for a given request pathname. 43 | * @param nextServer Next.js Node server instance 44 | * @param requestPathname Request pathname 45 | * @returns Object containing the filename and parameters 46 | */ 47 | export function findMatchingRoute( 48 | nextServer: NextNodeServer, 49 | requestPathname: string, 50 | ) { 51 | // @ts-expect-error - serverOptions is protected 52 | const basePath = nextServer.serverOptions?.conf.basePath || ''; 53 | const appPathRoutes = { 54 | // @ts-expect-error - appPathRoutes is protected 55 | ...nextServer.appPathRoutes, 56 | // @ts-expect-error - getAppPathRoutes is protected 57 | ...nextServer.getAppPathRoutes(), 58 | }; 59 | 60 | let matchedRoute = undefined; 61 | for (const [routePath, [filePath]] of Object.entries(appPathRoutes)) { 62 | if (!routePath || !filePath) continue; 63 | const realPath = `${basePath}${routePath}`; 64 | const routeParams = getRouteParams(realPath, requestPathname); 65 | if (routeParams) matchedRoute = { filename: filePath, params: routeParams }; 66 | } 67 | return matchedRoute; 68 | } 69 | -------------------------------------------------------------------------------- /src/server/persistent.ts: -------------------------------------------------------------------------------- 1 | function useGlobal(key: PropertyKey) { 2 | return [ 3 | function get() { 4 | return Reflect.get(globalThis, key) as T | undefined; 5 | }, 6 | function set(value: T) { 7 | return Reflect.set(globalThis, key, value); 8 | }, 9 | function use(getter: () => T) { 10 | const existing = Reflect.get(globalThis, key); 11 | if (existing) return existing as T; 12 | Reflect.set(globalThis, key, getter()); 13 | return Reflect.get(globalThis, key) as T; 14 | }, 15 | ] as const; 16 | } 17 | 18 | // ===== HTTP Server ===== // 19 | 20 | const [getHttpServer, setHttpServer, useHttpServer] = // 21 | useGlobal( 22 | Symbol.for('next-ws.http-server'), // 23 | ); 24 | 25 | export { 26 | /** 27 | * Get the HTTP server instance. 28 | * @returns Existing HTTP server instance if even 29 | */ 30 | getHttpServer, 31 | /** 32 | * Set the HTTP server instance. 33 | * @param value HTTP server instance 34 | */ 35 | setHttpServer, 36 | /** 37 | * Get or set the HTTP server instance. 38 | * @param getter Function to get the HTTP server instance 39 | * @returns Existing or created HTTP server instance 40 | */ 41 | useHttpServer, 42 | }; 43 | 44 | // ===== WebSocket Server ===== // 45 | 46 | const [getWebSocketServer, setWebSocketServer, useWebSocketServer] = // 47 | useGlobal( 48 | Symbol.for('next-ws.websocket-server'), // 49 | ); 50 | 51 | export { 52 | /** 53 | * Get the WebSocket server instance. 54 | * @returns Existing WebSocket server instance if even 55 | */ 56 | getWebSocketServer, 57 | /** 58 | * Set the WebSocket server instance. 59 | * @param value WebSocket server instance 60 | */ 61 | setWebSocketServer, 62 | /** 63 | * Get or set the WebSocket server instance. 64 | * @param getter Function to get the WebSocket server instance 65 | * @returns Existing or created WebSocket server instance 66 | */ 67 | useWebSocketServer, 68 | }; 69 | 70 | // ===== Request Storage ===== // 71 | 72 | const [getRequestStorage, setRequestStorage, useRequestStorage] = // 73 | useGlobal( 74 | Symbol.for('next-ws.request-store'), // 75 | ); 76 | 77 | export { 78 | /** 79 | * Get the request storage instance. 80 | * @returns Existing request storage instance if even 81 | */ 82 | getRequestStorage, 83 | /** 84 | * Set the request storage instance. 85 | * @param value Request storage instance 86 | */ 87 | setRequestStorage, 88 | /** 89 | * Get or set the request storage instance. 90 | * @param getter Function to get the request storage instance 91 | * @returns Existing or created request storage instance 92 | */ 93 | useRequestStorage, 94 | }; 95 | -------------------------------------------------------------------------------- /src/server/helpers/module.ts: -------------------------------------------------------------------------------- 1 | import * as logger from 'next/dist/build/output/log.js'; 2 | import type NextNodeServer from 'next/dist/server/next-server.js'; 3 | import type { SocketHandler, UpgradeHandler } from './socket.js'; 4 | 5 | /** 6 | * Imports the route module for a given file path. 7 | * @param nextServer Next.js Node server instance 8 | * @param filePathname File path 9 | * @returns Route module if existing 10 | */ 11 | export async function importRouteModule( 12 | nextServer: NextNodeServer, 13 | filePathname: string, 14 | ) { 15 | try { 16 | // CHANGE(next@14): hotReloader was removed and ensurePage was moved to NextNodeServer 17 | if ('hotReloader' in nextServer) { 18 | // @ts-expect-error - hotReloader only exists in Next.js 13 19 | await nextServer.hotReloader?.ensurePage({ 20 | page: filePathname, 21 | clientOnly: false, 22 | }); 23 | } else if ('ensurePage' in nextServer) { 24 | // ensurePage throws an error in production, so we need to catch it 25 | // @ts-expect-error - ensurePage is protected 26 | await nextServer.ensurePage({ page: filePathname, clientOnly: false }); 27 | } else { 28 | // Future-proofing 29 | logger.warnOnce( 30 | '[next-ws] unable to ensure page, you may need to open the route in your browser first so Next.js compiles it', 31 | ); 32 | } 33 | } catch {} 34 | 35 | try { 36 | // @ts-expect-error - getPageModule is protected 37 | const buildPagePath = nextServer.getPagePath(filePathname); 38 | return (await require(buildPagePath)).routeModule as RouteModule; 39 | } catch (cause) { 40 | console.error(cause); 41 | return undefined; 42 | } 43 | } 44 | 45 | /** 46 | * Route module, generated by Next.js. 47 | */ 48 | export interface RouteModule { 49 | userland: { 50 | /** @deprecated Prefer UPGRADE and {@link UpgradeHandler} */ 51 | SOCKET?: SocketHandler; 52 | UPGRADE?: UpgradeHandler; 53 | }; 54 | } 55 | 56 | /** 57 | * Extract the parameters from a route pattern. 58 | */ 59 | export type RouteParams = 60 | Pattern extends `${infer Before}[[...${infer Param}]]${infer After}` 61 | ? RouteParams & { [K in Param]?: string[] } & RouteParams 62 | : Pattern extends `${infer Before}[...${infer Param}]${infer After}` 63 | ? RouteParams & { [K in Param]: string[] } & RouteParams 64 | : Pattern extends `${infer Before}[${infer Param}]${infer After}` 65 | ? RouteParams & { [K in Param]: string } & RouteParams 66 | : // biome-ignore lint/complexity/noBannedTypes: do nothing 67 | {}; 68 | 69 | /** 70 | * Route context object containing the parameters. 71 | */ 72 | export type RouteContext = { 73 | params: Record & 74 | RouteParams & 75 | RouteParams; 76 | }; 77 | -------------------------------------------------------------------------------- /src/commands/helpers/console.ts: -------------------------------------------------------------------------------- 1 | import readline from 'node:readline'; 2 | import { debuglog as createDebugger } from 'node:util'; 3 | import chalk from 'chalk'; 4 | 5 | export function log(...message: unknown[]) { 6 | console.log('[next-ws]', ...message); 7 | } 8 | 9 | export function info(...message: unknown[]) { 10 | console.log(chalk.blue('[next-ws]'), ...message); 11 | } 12 | 13 | export function warn(...message: unknown[]) { 14 | console.log(chalk.yellow('[next-ws]'), ...message); 15 | } 16 | 17 | export function error(...message: unknown[]) { 18 | console.log(chalk.red('[next-ws]'), ...message); 19 | } 20 | 21 | export const debug = createDebugger('next-ws'); 22 | 23 | export function success(...message: unknown[]) { 24 | console.log(chalk.green('[next-ws]', '✔'), ...message); 25 | } 26 | 27 | export function failure(...message: unknown[]) { 28 | console.log(chalk.red('[next-ws]', '✖'), ...message); 29 | } 30 | 31 | /** 32 | * Show a confirmation prompt where the user can choose to confirm or deny. 33 | * @param message The message to show 34 | * @returns A promise that resolves to a boolean indicating whether the user confirmed or denied 35 | */ 36 | export async function confirm(...message: unknown[]) { 37 | const rl = readline.createInterface({ 38 | input: process.stdin, 39 | output: process.stdout, 40 | }); 41 | 42 | return new Promise((resolve) => { 43 | const question = chalk.yellow('[next-ws]', ...message); 44 | const options = chalk.cyan('[y/N]'); 45 | 46 | rl.question(`${question} ${options}`, (answer) => { 47 | const normalisedAnswer = answer.trim().toLowerCase(); 48 | if (normalisedAnswer === 'y') resolve(true); 49 | else resolve(false); 50 | rl.close(); 51 | }); 52 | }); 53 | } 54 | 55 | /** 56 | * Show a loading spinner while a promise is running. 57 | * @param promise The promise to run 58 | * @param message The message to show 59 | * @returns The result of the promise 60 | */ 61 | export async function task(promise: Promise, ...message: unknown[]) { 62 | // Hide the cursor 63 | process.stdout.write('\x1B[?25l'); 64 | 65 | const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; 66 | let spinnerIndex = 0; 67 | const spinnerInterval = setInterval(() => { 68 | readline.cursorTo(process.stdout, 0); 69 | const spinnerChar = 70 | spinnerChars[spinnerIndex++ % spinnerChars.length] ?? ' '; 71 | process.stdout.write(chalk.cyan('[next-ws]', spinnerChar, ...message)); 72 | }, 100); 73 | 74 | return promise 75 | .then((value) => { 76 | clearInterval(spinnerInterval); 77 | readline.cursorTo(process.stdout, 0); 78 | readline.clearLine(process.stdout, 0); 79 | success(...message); 80 | return value; 81 | }) 82 | .catch((err) => { 83 | clearInterval(spinnerInterval); 84 | readline.cursorTo(process.stdout, 0); 85 | readline.clearLine(process.stdout, 0); 86 | failure(...message); 87 | throw err; 88 | }) 89 | .finally(() => { 90 | // Show the cursor 91 | process.stdout.write('\x1B[?25h'); 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /src/server/setup.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks'; 2 | import * as logger from 'next/dist/build/output/log.js'; 3 | import type NextNodeServer from 'next/dist/server/next-server.js'; 4 | import { WebSocketServer } from 'ws'; 5 | import { findMatchingRoute } from './helpers/match.js'; 6 | import { importRouteModule } from './helpers/module.js'; 7 | import { toNextRequest } from './helpers/request.js'; 8 | import { createRequestStore } from './helpers/store.js'; 9 | import { 10 | useHttpServer, 11 | useRequestStorage, 12 | useWebSocketServer, 13 | } from './persistent.js'; 14 | 15 | /** 16 | * Attach the WebSocket server to the HTTP server. 17 | * @param nextServer Next.js Node server instance 18 | */ 19 | export function setupWebSocketServer(nextServer: NextNodeServer) { 20 | const httpServer = // 21 | // @ts-expect-error - serverOptions is protected 22 | useHttpServer(() => nextServer.serverOptions?.httpServer); 23 | if (!httpServer) 24 | return logger.error('[next-ws] was not able to find the HTTP server'); 25 | const wsServer = // 26 | useWebSocketServer(() => new WebSocketServer({ noServer: true })); 27 | const requestStorage = // 28 | useRequestStorage(() => new AsyncLocalStorage()); 29 | 30 | logger.ready('[next-ws] has started the WebSocket server'); 31 | 32 | // Prevent double-attaching 33 | const kAttached = Symbol.for('next-ws.http-server.attached'); 34 | if (Reflect.has(httpServer, kAttached)) return; 35 | Reflect.set(httpServer, kAttached, true); 36 | 37 | httpServer.on('upgrade', async (message, socket, head) => { 38 | const request = toNextRequest(message); 39 | 40 | const pathname = request.nextUrl.pathname; 41 | if (pathname.includes('/_next')) return; 42 | 43 | const route = findMatchingRoute(nextServer, pathname); 44 | if (!route) { 45 | logger.error(`[next-ws] could not find route for page ${pathname}`); 46 | return socket.end(); 47 | } 48 | 49 | const module = await importRouteModule(nextServer, route.filename); 50 | if (!module) { 51 | logger.error(`[next-ws] could not import module for page ${pathname}`); 52 | return socket.end(); 53 | } 54 | 55 | const handleUpgrade = module.userland.UPGRADE; 56 | const handleSocket = module.userland.SOCKET; 57 | if ( 58 | (!handleUpgrade || typeof handleUpgrade !== 'function') && 59 | (!handleSocket || typeof handleSocket !== 'function') 60 | ) { 61 | logger.error(`[next-ws] route '${pathname}' does not export a handler`); 62 | return socket.end(); 63 | } 64 | if (handleSocket) 65 | logger.warnOnce( 66 | 'DeprecationWarning: [next-ws] SOCKET is deprecated, prefer UPGRADE instead, see https://github.com/apteryxxyz/next-ws#-usage', 67 | ); 68 | 69 | wsServer.handleUpgrade(message, socket, head, async (client) => { 70 | wsServer.emit('connection', client, message); 71 | 72 | try { 73 | const context = { params: route.params }; 74 | if (handleUpgrade) { 75 | await requestStorage.run( 76 | createRequestStore(request), // 77 | () => handleUpgrade(client, wsServer, request, context), 78 | ); 79 | } 80 | // 81 | else if (handleSocket) { 82 | const handleClose = // 83 | await handleSocket(client, message, wsServer, context); 84 | if (typeof handleClose === 'function') 85 | client.once('close', () => handleClose()); 86 | } 87 | } catch (cause) { 88 | logger.error( 89 | `[next-ws] error in socket handler for '${pathname}'`, 90 | cause, 91 | ); 92 | try { 93 | client.close(1011, 'Internal Server Error'); 94 | } catch {} 95 | } 96 | }); 97 | 98 | return; 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/commands/helpers/define.ts: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist'; 2 | import { version } from '../../../package.json'; 3 | 4 | export interface Definition { 5 | name: string; 6 | description: string; 7 | } 8 | 9 | // ===== CommandGroup ===== // 10 | 11 | export interface CommandGroupDefinition extends Definition { 12 | children: (CommandGroup | Command)[]; 13 | } 14 | 15 | export interface CommandGroup extends CommandGroupDefinition { 16 | parse(parents: CommandGroup[], argv: string[]): void; 17 | } 18 | 19 | /** 20 | * Define a command group. 21 | * @param definition The definition for the command group 22 | * @returns The command group 23 | */ 24 | export function defineCommandGroup( 25 | definition: CommandGroupDefinition, 26 | ): CommandGroup { 27 | return { 28 | ...definition, 29 | parse(parents: CommandGroup[], argv: string[]) { 30 | const parsed = minimist(argv); 31 | for (const child of this.children) 32 | if (parsed._[0] === child.name) 33 | return void child.parse(parents.concat(this), argv.slice(1)); 34 | if (parsed.help) 35 | return void console.log(buildCommandGroupHelp(parents, this)); 36 | if (parsed.v || parsed.version) return void console.log(version); 37 | return void console.log(buildCommandGroupHelp(parents, this)); 38 | }, 39 | }; 40 | } 41 | 42 | /** 43 | * Build the help message for a command group. 44 | * @param parents List of parent command groups used to build the usage 45 | * @param group The command group to build the help message for 46 | * @returns The help message for the command group 47 | */ 48 | function buildCommandGroupHelp(parents: CommandGroup[], group: CommandGroup) { 49 | return `Usage: ${[...parents, group].map((p) => p.name).join(' ')} [command] [options] 50 | 51 | ${group.description} 52 | 53 | Commands: 54 | ${group.children.map((c) => `${c.name} | ${c.description}`).join('\n ')} 55 | 56 | Options: 57 | --help | Show this help message and exit. 58 | --version | Show the version number and exit. 59 | `; 60 | } 61 | 62 | // ===== Command ===== // 63 | 64 | export interface CommandDefinition 65 | extends Definition { 66 | options: TOptions; 67 | action( 68 | options: Record, 69 | ): Promise | void; 70 | } 71 | 72 | export interface Command< 73 | TOptions extends OptionDefinition[] = OptionDefinition[], 74 | > extends CommandDefinition { 75 | parse(parents: CommandGroup[], argv: string[]): void; 76 | } 77 | 78 | /** 79 | * Define a command. 80 | * @param definition The definition for the command 81 | * @returns The command 82 | */ 83 | export function defineCommand( 84 | definition: CommandDefinition, 85 | ): Command { 86 | return { 87 | ...definition, 88 | parse(parents: CommandGroup[], argv: string[]) { 89 | const parsed = minimist(argv); 90 | if (parsed.help) return void console.log(buildCommandHelp(parents, this)); 91 | return this.action(parsed as never); 92 | }, 93 | }; 94 | } 95 | 96 | /** 97 | * Build the help message for a command. 98 | * @param parents List of parent command groups used to build the usage 99 | * @param command The command to build the help message for 100 | * @returns The help message for the command 101 | */ 102 | function buildCommandHelp(parents: CommandGroup[], command: Command) { 103 | return `Usage: ${[...parents, command].map((p) => p.name).join(' ')} [options] 104 | 105 | ${command.description} 106 | 107 | Options: 108 | --help | Show this help message and exit. 109 | ${command.options.map((o) => `--${o.name} | ${o.description}`).join('\n ')} 110 | `; 111 | } 112 | 113 | // ===== Option ===== // 114 | 115 | export interface OptionDefinition extends Definition { 116 | alias?: string | string[]; 117 | } 118 | -------------------------------------------------------------------------------- /.github/workflows/bump-next-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump Next.js Version 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 */6 * * *" 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | check-next-version: 14 | name: Check Next.js Version 15 | runs-on: ubuntu-latest 16 | outputs: 17 | bump_needed: ${{ steps.compare-versions.outputs.bump_needed }} 18 | next_version: ${{ steps.compare-versions.outputs.next_version }} 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js 25 | uses: ./.github/actions/node 26 | 27 | - name: Get current Next.js version 28 | id: get-current-version 29 | run: | 30 | package=$(pnpm list next --json) 31 | current_version=$(echo "$package" | jq -r '.[0].devDependencies.next.version') 32 | echo "Currently using Next.js version $current_version" 33 | echo "current_version=$current_version" >> $GITHUB_ENV 34 | 35 | - name: Get latest Next.js version 36 | id: get-latest-version 37 | run: | 38 | latest_version=$(pnpm show next version) 39 | echo "Latest version is $latest_version" 40 | echo "latest_version=$latest_version" >> $GITHUB_ENV 41 | 42 | - name: Compare versions 43 | id: compare-versions 44 | run: | 45 | echo "Comparing versions $current_version and $latest_version" 46 | if [ "$latest_version" != "$current_version" ]; then 47 | echo "Version bump needed" 48 | echo "bump_needed=true" >> $GITHUB_OUTPUT 49 | echo "next_version=$latest_version" >> $GITHUB_OUTPUT 50 | else 51 | echo "Version is up to date" 52 | echo "bump_needed=false" >> $GITHUB_OUTPUT 53 | fi 54 | 55 | update-next-version: 56 | name: Update Next.js Version 57 | needs: [check-next-version] 58 | if: ${{ needs.check-next-version.outputs.bump_needed == 'true' }} 59 | runs-on: ubuntu-latest 60 | 61 | steps: 62 | - name: Checkout repository 63 | uses: actions/checkout@v4 64 | 65 | - name: Setup Node.js 66 | uses: ./.github/actions/node 67 | 68 | - name: Update Next.js version 69 | run: | 70 | next_version="${{ needs.check-next-version.outputs.next_version }}" 71 | echo "Updating pnpm-workspace.yaml catalog to $next_version" 72 | sed -i "s/next: .*/next: \"$next_version\"/" "pnpm-workspace.yaml" 73 | pnpm install --lockfile-only 74 | 75 | - name: Bump supported range 76 | run: | 77 | next_version="${{ needs.check-next-version.outputs.next_version }}" 78 | patch_file=$(ls src/patches/patch-*.ts | sort -V | tail -n 1) 79 | echo "Updating $patch_file to $next_version" 80 | sed -i "s/\(versions: .*\) <=[0-9\.]\+/\1 <=$next_version/" "$patch_file" 81 | 82 | - name: Add new changeset 83 | run: | 84 | unique_id=$(tr -dc A-Za-z0-9 > .changeset/$unique_id.md 87 | echo "Bump patch supported range to ${{ needs.check-next-version.outputs.next_version }}" >> .changeset/$unique_id.md 88 | 89 | - name: Make pull request 90 | uses: peter-evans/create-pull-request@v5 91 | with: 92 | token: ${{ secrets.GH_PULL_REQUEST_TOKEN }} 93 | commit-message: "Bump patch supported range to ${{ needs.check-next-version.outputs.next_version }}" 94 | branch: "chore/bump-nextjs-${{ needs.check-next-version.outputs.next_version }}" 95 | title: "Bump patch supported range to ${{ needs.check-next-version.outputs.next_version }}" 96 | body: "Bump patch supported range to ${{ needs.check-next-version.outputs.next_version }}" 97 | delete-branch: true 98 | -------------------------------------------------------------------------------- /tests/chat-room.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page, test } from '@playwright/test'; 2 | 3 | let page1: Page, page2: Page; 4 | test.beforeEach(async ({ browser }) => { 5 | page1 = await browser.newPage(); 6 | page2 = await browser.newPage(); 7 | }); 8 | test.afterEach(async () => { 9 | await page1.close(); 10 | await page2.close(); 11 | }); 12 | 13 | test.use({ baseURL: 'http://localhost:3001' }); 14 | 15 | test.describe('Chat Room', () => { 16 | test('a user joins the chat and receives a welcome message', async () => { 17 | await page1.goto('/'); 18 | 19 | const welcome1 = await page1.textContent('li:first-child'); 20 | expect(welcome1).toContain('Welcome to the chat!'); 21 | expect(welcome1).toContain('There are no other users online'); 22 | 23 | await page2.goto('/'); 24 | 25 | const welcome2 = await page2.textContent('li:first-child'); 26 | expect(welcome2).toContain('Welcome to the chat!'); 27 | expect(welcome2).toContain('There is 1 other user online'); 28 | }); 29 | 30 | test('a new user joins the chat and all users receive a message', async () => { 31 | await page1.goto('/'); 32 | await page2.goto('/'); 33 | 34 | await page1.waitForTimeout(1000); // Can take a moment 35 | const message1 = await page1.textContent('li:last-child'); 36 | expect(message1).toContain('A new user joined the chat'); 37 | }); 38 | 39 | test('a user sends a message and all users receive it', async () => { 40 | await page1.goto('/'); 41 | await page2.goto('/'); 42 | 43 | await page1.fill('input[name=author]', 'Alice'); 44 | await page1.fill('input[name=content]', 'Hello, world!'); 45 | await page1.click('button[type=submit]'); 46 | 47 | const message1 = await page1.textContent('li:last-child'); 48 | expect(message1).toContain('You'); 49 | expect(message1).toContain('Hello, world!'); 50 | 51 | const message2 = await page2.textContent('li:last-child'); 52 | expect(message2).toContain('Alice'); 53 | expect(message2).toContain('Hello, world!'); 54 | }); 55 | }); 56 | 57 | test.describe('Private Chat Room', () => { 58 | test('a user joins the chat and receives a welcome message with their dynamic value', async () => { 59 | const code = Math.random().toString(16).slice(2, 6).toUpperCase(); 60 | 61 | await page1.goto(`/${code}`); 62 | 63 | const welcome1 = await page1.textContent('li:first-child'); 64 | expect(welcome1).toContain(`Welcome to the ${code} chat!`); 65 | expect(welcome1).toContain('There are no other users online'); 66 | 67 | await page2.goto(`/${code}`); 68 | 69 | const welcome2 = await page2.textContent('li:first-child'); 70 | expect(welcome2).toContain(`Welcome to the ${code} chat!`); 71 | expect(welcome2).toContain('There is 1 other user online'); 72 | }); 73 | 74 | test('a new user joins the chat and all users receive a message with their dynamic value', async () => { 75 | const code = Math.random().toString(16).slice(2, 6).toUpperCase(); 76 | 77 | await page1.goto(`/${code}`); 78 | await page2.goto(`/${code}`); 79 | 80 | await page1.waitForTimeout(1000); // Can take a moment 81 | const message1 = await page1.textContent('li:last-child'); 82 | expect(message1).toContain(`A new user joined the ${code} chat`); 83 | }); 84 | 85 | test('a user sends a message and all users receive it', async () => { 86 | const code = Math.random().toString(16).slice(2, 6).toUpperCase(); 87 | 88 | await page1.goto(`/${code}`); 89 | await page2.goto(`/${code}`); 90 | 91 | await page1.fill('input[name=author]', 'Alice'); 92 | await page1.fill('input[name=content]', 'Hello, world!'); 93 | await page1.click('button[type=submit]'); 94 | 95 | const message1 = await page1.textContent('li:last-child'); 96 | expect(message1).toContain('You'); 97 | expect(message1).toContain('Hello, world!'); 98 | 99 | const message2 = await page2.textContent('li:last-child'); 100 | expect(message2).toContain('Alice'); 101 | expect(message2).toContain('Hello, world!'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /tests/custom-server.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page, test } from '@playwright/test'; 2 | 3 | let page1: Page, page2: Page; 4 | test.beforeEach(async ({ browser }) => { 5 | page1 = await browser.newPage(); 6 | page2 = await browser.newPage(); 7 | }); 8 | test.afterEach(async () => { 9 | await page1.close(); 10 | await page2.close(); 11 | }); 12 | 13 | test.use({ baseURL: 'http://localhost:3003' }); 14 | 15 | test.describe('Chat Room', () => { 16 | test('a user joins the chat and receives a welcome message', async () => { 17 | await page1.goto('/'); 18 | 19 | const welcome1 = await page1.textContent('li:first-child'); 20 | expect(welcome1).toContain('Welcome to the chat!'); 21 | expect(welcome1).toContain('There are no other users online'); 22 | 23 | await page2.goto('/'); 24 | 25 | const welcome2 = await page2.textContent('li:first-child'); 26 | expect(welcome2).toContain('Welcome to the chat!'); 27 | expect(welcome2).toContain('There is 1 other user online'); 28 | }); 29 | 30 | test('a new user joins the chat and all users receive a message', async () => { 31 | await page1.goto('/'); 32 | await page2.goto('/'); 33 | 34 | await page1.waitForTimeout(1000); // Can take a moment 35 | const message1 = await page1.textContent('li:last-child'); 36 | expect(message1).toContain('A new user joined the chat'); 37 | }); 38 | 39 | test('a user sends a message and all users receive it', async () => { 40 | await page1.goto('/'); 41 | await page2.goto('/'); 42 | 43 | await page1.fill('input[name=author]', 'Alice'); 44 | await page1.fill('input[name=content]', 'Hello, world!'); 45 | await page1.click('button[type=submit]'); 46 | 47 | const message1 = await page1.textContent('li:last-child'); 48 | expect(message1).toContain('You'); 49 | expect(message1).toContain('Hello, world!'); 50 | 51 | const message2 = await page2.textContent('li:last-child'); 52 | expect(message2).toContain('Alice'); 53 | expect(message2).toContain('Hello, world!'); 54 | }); 55 | }); 56 | 57 | test.describe('Private Chat Room', () => { 58 | test('a user joins the chat and receives a welcome message with their dynamic value', async () => { 59 | const code = Math.random().toString(16).slice(2, 6).toUpperCase(); 60 | 61 | await page1.goto(`/${code}`); 62 | 63 | const welcome1 = await page1.textContent('li:first-child'); 64 | expect(welcome1).toContain(`Welcome to the ${code} chat!`); 65 | expect(welcome1).toContain('There are no other users online'); 66 | 67 | await page2.goto(`/${code}`); 68 | 69 | const welcome2 = await page2.textContent('li:first-child'); 70 | expect(welcome2).toContain(`Welcome to the ${code} chat!`); 71 | expect(welcome2).toContain('There is 1 other user online'); 72 | }); 73 | 74 | test('a new user joins the chat and all users receive a message with their dynamic value', async () => { 75 | const code = Math.random().toString(16).slice(2, 6).toUpperCase(); 76 | 77 | await page1.goto(`/${code}`); 78 | await page2.goto(`/${code}`); 79 | 80 | await page1.waitForTimeout(1000); // Can take a moment 81 | const message1 = await page1.textContent('li:last-child'); 82 | expect(message1).toContain(`A new user joined the ${code} chat`); 83 | }); 84 | 85 | test('a user sends a message and all users receive it', async () => { 86 | const code = Math.random().toString(16).slice(2, 6).toUpperCase(); 87 | 88 | await page1.goto(`/${code}`); 89 | await page2.goto(`/${code}`); 90 | 91 | await page1.fill('input[name=author]', 'Alice'); 92 | await page1.fill('input[name=content]', 'Hello, world!'); 93 | await page1.click('button[type=submit]'); 94 | 95 | const message1 = await page1.textContent('li:last-child'); 96 | expect(message1).toContain('You'); 97 | expect(message1).toContain('Hello, world!'); 98 | 99 | const message2 = await page2.textContent('li:last-child'); 100 | expect(message2).toContain('Alice'); 101 | expect(message2).toContain('Hello, world!'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Next WS

3 | Add support for WebSockets in Next.js app directory
4 | npm install next-ws ws 5 |
6 | 7 |
8 | package version 9 | total downloads 10 |
11 | next-ws repo stars 12 | apteryxxyz followers 13 | discord shield 14 |
15 | 16 | ## 🤔 About 17 | 18 | `next-ws` is a simple package that adds WebSocket support to your Next.js app directory. With `next-ws`, you no longer need to create a separate WebSocket server to handle WebSocket connections. Instead, you can handle WebSocket connections directly in your Next.js API routes. 19 | 20 | > [!IMPORTANT] 21 | > Next WS is designed for use in server-based environments. It is not suitable for serverless platforms like Vercel, where WebSocket servers are not supported. Furthermore, this plugin is built for the app directory and does not support the older pages directory. 22 | 23 | ## 🏓 Table of Contents 24 | 25 | - [📦 Installation](#-installation) 26 | - [🚀 Usage](#-usage) 27 | - [🌀 Examples](#-examples) 28 | 29 | ## 📦 Installation 30 | 31 | To set up a WebSocket server with `next-ws`, you need to patch your local Next.js installation. `next-ws` simplifies this process by providing a CLI command that handles the patching for you. Follow these steps to get started: 32 | 33 | 1. **Install Dependencies**: Use your preferred package manager to install `next-ws` and its peer dependency `ws`: 34 | 35 | ```bash 36 | npm install next-ws ws 37 | pnpm add next-ws ws 38 | yarn add next-ws ws 39 | ``` 40 | 41 | 2. **Add Prepare Script**: Add the following `prepare` script to your `package.json`. The `prepare` script is a lifecycle script that runs automatically when you run `npm install`, ensuring that your Next.js installation is patched with `next-ws` every time you install it: 42 | 43 | ```json 44 | { 45 | "scripts": { 46 | "prepare": "next-ws patch" 47 | } 48 | } 49 | ``` 50 | 51 | ## 🚀 Usage 52 | 53 | Using WebSocket connections in your Next.js app directory is simple with `next-ws`. You can handle WebSocket connections directly in your API routes via exported `UPGRADE` functions. 54 | 55 | ```js 56 | export function UPGRADE( 57 | client: import('ws').WebSocket, 58 | server: import('ws').WebSocketServer, 59 | request: import('next/server').NextRequest, 60 | context: import('next-ws/server').RouteContext<'/api/ws'>, 61 | ) { 62 | // ... 63 | } 64 | ``` 65 | 66 | ## 🌀 Examples 67 | 68 | > [!TIP] 69 | > For more detailed examples, refer the [`examples` directory](https://github.com/apteryxxyz/next-ws/tree/main/examples). 70 | 71 | ### Echo Server 72 | 73 | This example demonstrates a simple WebSocket echo server that sends back any message it receives. Create a new API route file anywhere in your app directory and export a `UPGRADE` function to handle WebSocket connections: 74 | 75 | ```ts 76 | // app/api/ws/route.ts (can be any route file in the app directory) 77 | 78 | export function UPGRADE( 79 | client: import('ws').WebSocket, 80 | server: import('ws').WebSocketServer 81 | ) { 82 | console.log('A client connected'); 83 | 84 | client.on('message', (message) => { 85 | console.log('Received message:', message); 86 | client.send(message); 87 | }); 88 | 89 | client.once('close', () => { 90 | console.log('A client disconnected'); 91 | }); 92 | } 93 | ``` 94 | 95 | You can now connect to your WebSocket server, send it a message and receive the same message back. 96 | 97 | ### Chat Room 98 | 99 | See the [chat room example](https://github.com/apteryxxyz/next-ws/tree/main/examples/chat-room). 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # next-ws 2 | 3 | ## 2.1.10 4 | 5 | ### Patch Changes 6 | 7 | - 02f578a: Bump patch supported range to 16.0.10 8 | 9 | ## 2.1.9 10 | 11 | ### Patch Changes 12 | 13 | - cbffdcf: Bump patch supported range to 16.0.8 14 | 15 | ## 2.1.8 16 | 17 | ### Patch Changes 18 | 19 | - 87152c2: Bump patch supported range to 16.0.5 20 | - 1435fb2: Bump patch supported range to 16.0.6 21 | - 9d049a0: Bump patch supported range to 16.0.7 22 | 23 | ## 2.1.7 24 | 25 | ### Patch Changes 26 | 27 | - 36e429f: Bump patch supported range to 16.0.2 28 | - 9c636dc: Bump patch supported range to 16.0.4 29 | - 4c8be19: Bump patch supported range to 16.0.3 30 | 31 | ## 2.1.6 32 | 33 | ### Patch Changes 34 | 35 | - 782a2d6: Bump patch supported range to 16.0.0 36 | - ecddf0b: Bump patch supported range to 16.0.1 37 | 38 | ## 2.1.5 39 | 40 | ### Patch Changes 41 | 42 | - 751dcc6: Bump patch supported range to 15.5.5 43 | - 8e4e1bc: Bump patch supported range to 15.5.6 44 | 45 | ## 2.1.4 46 | 47 | ### Patch Changes 48 | 49 | - 928b966: Bump patch supported range to 15.5.4 50 | 51 | ## 2.1.3 52 | 53 | ### Patch Changes 54 | 55 | - f3ac6fc: Bump patch supported range to 15.5.3 56 | 57 | ## 2.1.2 58 | 59 | ### Patch Changes 60 | 61 | - 5624dda: Add patch step to allow UPGRADE exports from route modules 62 | 63 | ## 2.1.1 64 | 65 | ### Patch Changes 66 | 67 | - fde9dc7: Bump patch supported range to 15.5.1 68 | - e318e37: Bump patch supported range to 15.5.2 69 | 70 | ## 2.1.0 71 | 72 | ### Minor Changes 73 | 74 | - 2d2926b: Migrate patches to use ast parser instead of find/replace 75 | - 30be438: Remove environment checks for getting persistent variables 76 | - 1e29ec2: Drop esm build, ship cjs only 77 | - 806c873: Introduce `UPGRADE` handler, mark `SOCKET` as deprecated 78 | - b981361: Add request async storage for upgrade handlers 79 | - 064cfd4: Catch and handle errors inside socket handlers 80 | 81 | ### Patch Changes 82 | 83 | - 064cfd4: Fix not emitting connection event on websocket server 84 | - 064cfd4: Prevent double-attaching upgrade listener 85 | - 064cfd4: Support optional catch-all routes in matcher 86 | 87 | ## 2.0.14 88 | 89 | ### Patch Changes 90 | 91 | - 9611355: Bump patch supported range to 15.4.7 92 | - eff3087: Bump patch supported range to 15.5.0 93 | 94 | ## 2.0.13 95 | 96 | ### Patch Changes 97 | 98 | - 86e459f: Bump patch supported range to 15.4.6 99 | 100 | ## 2.0.12 101 | 102 | ### Patch Changes 103 | 104 | - 8b2a27e: Bump patch supported range to 15.4.2 105 | - ff6aa44: Bump patch supported range to 15.4.3 106 | 107 | ## 2.0.11 108 | 109 | ### Patch Changes 110 | 111 | - f4defd1: Bump patch supported range to 15.4.1 112 | 113 | ## 2.0.10 114 | 115 | ### Patch Changes 116 | 117 | - 16a1e06: Bump patch supported range to 15.3.5 118 | - b68bfdd: Escape dist path for safe require() usage 119 | 120 | ## 2.0.9 121 | 122 | ### Patch Changes 123 | 124 | - 0c769e0: Bump patch supported range to 15.3.4 125 | 126 | ## 2.0.8 127 | 128 | ### Patch Changes 129 | 130 | - 676a3f4: Bump patch supported range to 15.3.3 131 | - 97fc08c: Bump patch supported range to 15.3.2 132 | 133 | ## 2.0.7 134 | 135 | ### Patch Changes 136 | 137 | - ed876c4: Bump patch supported range to 15.3.1 138 | - 01df147: Await default property of route module 139 | 140 | ## 2.0.6 141 | 142 | ### Patch Changes 143 | 144 | - 8b30765: Add support for next config `basePath` 145 | - 1051ec2: Bump patch supported range to 15.2.4 146 | 147 | ## 2.0.5 148 | 149 | ### Patch Changes 150 | 151 | - 2af0987: Bump patch supported range to 15.2.3 152 | 153 | ## 2.0.4 154 | 155 | ### Patch Changes 156 | 157 | - c37c965: Bump patch supported range to 15.2.2 158 | 159 | ## 2.0.3 160 | 161 | ### Patch Changes 162 | 163 | - 0fe2960: Bump patch supported range to 15.2.1 164 | 165 | ## 2.0.2 166 | 167 | ### Patch Changes 168 | 169 | - eee09c9: Bump patch supported range to 15.2.0 170 | - b3a28f0: Mark client context as deprecated 171 | 172 | ## 2.0.1 173 | 174 | ### Patch Changes 175 | 176 | - f0dbc9e: Fix not correctly getting socket handler on custom servers 177 | - 442c788: Reduce package bundle size 178 | 179 | ## 2.0.0 180 | 181 | ### Major Changes 182 | 183 | - 339915b: Merged the core and CLI packages into a single package. This change does not introduce any breaking changes to the public API of the core package. 184 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ["package.json", "src/**", "tests/**"] 7 | pull_request: 8 | branches: [main] 9 | types: [opened, synchronize, reopened] 10 | paths: ["package.json", "src/**", "tests/**"] 11 | workflow_dispatch: 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | check: 19 | name: Type Checking 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Node.js 27 | uses: ./.github/actions/node 28 | 29 | - name: Run the type checker 30 | run: pnpm check 31 | 32 | lint: 33 | name: Linting 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | - name: Setup Node.js 41 | uses: ./.github/actions/node 42 | 43 | - name: Run the linter 44 | run: pnpm lint 45 | 46 | build: 47 | name: Building 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4 53 | 54 | - name: Setup Node.js 55 | uses: ./.github/actions/node 56 | 57 | - name: Build the package 58 | run: pnpm build 59 | 60 | test: 61 | name: Testing 62 | needs: [build] 63 | runs-on: ubuntu-latest 64 | 65 | steps: 66 | - name: Checkout repository 67 | uses: actions/checkout@v4 68 | 69 | - name: Setup Node.js 70 | uses: ./.github/actions/node 71 | 72 | - name: Run tests 73 | uses: docker://mcr.microsoft.com/playwright:v1.55.0-jammy 74 | with: 75 | args: npx playwright test 76 | 77 | - name: Upload test outputs 78 | if: failure() 79 | uses: actions/upload-artifact@v4 80 | with: 81 | name: test-outputs 82 | path: | 83 | tests/.report/ 84 | tests/.results/ 85 | include-hidden-files: true 86 | retention-days: 7 87 | 88 | process: 89 | name: Processing Changesets 90 | needs: [test] 91 | runs-on: ubuntu-latest 92 | outputs: 93 | published: ${{ steps.changesets.outputs.published }} 94 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 95 | 96 | steps: 97 | - name: Checkout repository 98 | uses: actions/checkout@v4 99 | 100 | - name: Setup Node.js 101 | uses: ./.github/actions/node 102 | 103 | - name: Process changesets 104 | id: changesets 105 | uses: changesets/action@v1 106 | with: 107 | version: pnpm run release:version 108 | publish: pnpm run release:publish 109 | title: "Pending Releases" 110 | commit: "Update changelog and release" 111 | env: 112 | GITHUB_TOKEN: ${{ secrets.GH_PULL_REQUEST_TOKEN }} 113 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 114 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 115 | 116 | - run: | 117 | echo "published=${{ steps.changesets.outputs.published }}" >> $GITHUB_OUTPUT 118 | 119 | deprecate: 120 | name: Deprecating Beta Versions 121 | needs: [process] 122 | runs-on: ubuntu-latest 123 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.process.outputs.published == 'true' }} 124 | 125 | steps: 126 | - name: Checkout repository 127 | uses: actions/checkout@v4 128 | 129 | - name: Setup Node.js 130 | uses: ./.github/actions/node 131 | 132 | - name: Deprecate beta versions 133 | run: | 134 | pnpm npm-deprecate --package next-ws --name "*beta*" --deprecate-dist-tag --message "This beta version has been merged into the latest release. Please upgrade to the latest version." 135 | env: 136 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 137 | 138 | snapshot: 139 | name: Releasing Snapshot 140 | needs: [process] 141 | runs-on: ubuntu-latest 142 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.process.outputs.published == 'false' }} 143 | 144 | steps: 145 | - name: Checkout repository 146 | uses: actions/checkout@v4 147 | 148 | - name: Setup Node.js 149 | uses: ./.github/actions/node 150 | 151 | - name: Publish snapshot version 152 | run: | 153 | pnpm run release:snapshot:version 154 | pnpm run release:snapshot:publish 155 | env: 156 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 157 | continue-on-error: true 158 | -------------------------------------------------------------------------------- /src/patches/patch-1.ts: -------------------------------------------------------------------------------- 1 | import { sep } from 'node:path'; 2 | import $ from 'jscodeshift'; 3 | import { definePatch, definePatchStep } from './helpers/define.js'; 4 | import { resolveNextWsDirectory } from './helpers/next.js'; 5 | 6 | const CommentLine = $.Comment as typeof $.CommentLine; 7 | 8 | /** 9 | * Add `require('next-ws/server').setupWebSocketServer(this)` to the constructor of 10 | * `NextNodeServer` in `next/dist/server/next-server.js`. 11 | * @remark Starting the server and handling connections is part of the core 12 | */ 13 | export const patchNextNodeServer = definePatchStep({ 14 | title: 'Add WebSocket server setup script to NextNodeServer constructor', 15 | path: 'next:dist/server/next-server.js', 16 | async transform(code) { 17 | const marker = '@patch attach-websocket-server'; 18 | const snippet = $(` 19 | // ${marker} 20 | let nextWs; 21 | try { nextWs ??= require('next-ws/server') } catch {} 22 | try { nextWs ??= require(require.resolve('next-ws/server', { paths: [process.cwd()] }) )} catch {} 23 | try { nextWs ??= require('${resolveNextWsDirectory().replaceAll(sep, '/').replaceAll("'", "\\'")}/dist/server/index.cjs') } catch {} 24 | nextWs?.setupWebSocketServer(this); 25 | `); 26 | const block = $.blockStatement(snippet.nodes()[0].program.body); 27 | 28 | return $(code) 29 | .find($.ClassDeclaration, { id: { name: 'NextNodeServer' } }) 30 | .find($.MethodDefinition, { kind: 'constructor' }) 31 | .forEach(({ node }) => { 32 | const body = (node.value.body as $.BlockStatement).body; 33 | 34 | const existing = $(body) 35 | .find(CommentLine, { value: ` ${marker}` }) 36 | .paths()[0]; 37 | const idx = body.indexOf(existing?.parent.node); 38 | 39 | if (existing && idx > -1) body[idx] = block; 40 | else body.push(block); 41 | }) 42 | .toSource(); 43 | }, 44 | }); 45 | 46 | /** 47 | * Prevent Next.js from immediately closing WebSocket connections on matched routes. 48 | */ 49 | export const patchRouterServer = definePatchStep({ 50 | title: 'Prevent Next.js from immediately closing WebSocket connections', 51 | path: 'next:dist/server/lib/router-server.js', 52 | async transform(code) { 53 | return $(code) 54 | .find($.ReturnStatement) 55 | .find($.CallExpression, { 56 | callee: { 57 | type: 'MemberExpression', 58 | object: { type: 'Identifier', name: 'socket' }, 59 | property: { type: 'Identifier', name: 'end' }, 60 | }, 61 | }) 62 | .replaceWith((path) => { 63 | const expr = $.unaryExpression('void', $.literal(0)); // void 0 64 | expr.comments = [$.commentLine(` ${$(path.node).toSource()}`)]; 65 | return expr; 66 | }) 67 | .toSource(); 68 | }, 69 | }); 70 | 71 | /** 72 | * Add UPGRADE as allowed route HTTP method. 73 | */ 74 | export const patchNextTypesPlugin = definePatchStep({ 75 | title: 'Add UPGRADE as allowed export from route modules', 76 | path: 'next:dist/build/webpack/plugins/next-types-plugin/index.js', 77 | async transform(code) { 78 | return $(code) 79 | .find($.MemberExpression, { 80 | property: { name: 'HTTP_METHODS' }, 81 | }) 82 | .at(0) 83 | .replaceWith((path) => { 84 | if (path.parent.value.type === 'SpreadElement') return path.node; 85 | return $.arrayExpression([ 86 | $.spreadElement(path.node), 87 | $.literal('UPGRADE'), 88 | $.literal('SOCKET'), 89 | ]); 90 | }) 91 | .toSource(); 92 | }, 93 | }); 94 | 95 | /** 96 | * Add WebSocket contextual headers resolution to request headers. 97 | */ 98 | export const patchHeaders = definePatchStep({ 99 | title: 'Add WebSocket contextual headers resolution to request headers', 100 | path: 'next:dist/client/components/headers.js', 101 | async transform(code) { 102 | const marker = '@patch headers'; 103 | const snippet = $(` 104 | // ${marker} 105 | const kRequestStorage = Symbol.for('next-ws.request-store'); 106 | const requestStorage = Reflect.get(globalThis, kRequestStorage); 107 | const contextualHeaders = requestStorage?.getStore()?.headers; 108 | if (contextualHeaders) return contextualHeaders; 109 | `); 110 | const block = $.blockStatement(snippet.nodes()[0].program.body); 111 | 112 | return $(code) 113 | .find($.FunctionDeclaration, { id: { name: 'headers' } }) 114 | .forEach(({ node }) => { 115 | const body = node.body.body; 116 | 117 | const existing = $(body) 118 | .find(CommentLine, { value: ` ${marker}` }) 119 | .paths()[0]; 120 | const idx = body.indexOf(existing?.parent.node); 121 | 122 | if (existing && idx > -1) body[idx] = block; 123 | else body.unshift(block); 124 | }) 125 | .toSource(); 126 | }, 127 | }); 128 | 129 | /** 130 | * Add WebSocket contextual cookies resolution to request cookies. 131 | */ 132 | export const patchCookies = definePatchStep({ 133 | title: 'Add WebSocket contextual cookies resolution to request cookies', 134 | path: 'next:dist/client/components/headers.js', 135 | async transform(code) { 136 | const marker = '@patch cookies'; 137 | const snippet = $(` 138 | // ${marker} 139 | const kRequestStorage = Symbol.for('next-ws.request-store'); 140 | const requestStorage = Reflect.get(globalThis, kRequestStorage); 141 | const contextualCookies = requestStorage?.getStore()?.cookies; 142 | if (contextualCookies) return contextualCookies; 143 | `); 144 | const block = $.blockStatement(snippet.nodes()[0].program.body); 145 | 146 | return $(code) 147 | .find($.FunctionDeclaration, { id: { name: 'cookies' } }) 148 | .forEach(({ node }) => { 149 | const body = node.body.body; 150 | 151 | const existing = $(body) 152 | .find(CommentLine, { value: ` ${marker}` }) 153 | .paths()[0]; 154 | const idx = body.indexOf(existing?.parent.node); 155 | 156 | if (existing && idx > -1) body[idx] = block; 157 | else body.unshift(block); 158 | }) 159 | .toSource(); 160 | }, 161 | }); 162 | 163 | export default definePatch({ 164 | name: 'patch-1', 165 | versions: '>=13.5.1 <=14.2.32', 166 | steps: [ 167 | patchNextNodeServer, 168 | patchNextTypesPlugin, 169 | patchRouterServer, 170 | patchHeaders, 171 | patchCookies, 172 | ], 173 | }); 174 | --------------------------------------------------------------------------------