├── 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 |
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 |

9 |

10 |
11 |

12 |

13 |

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 |
--------------------------------------------------------------------------------