diff --git a/.eslintrc.js b/.eslintrc.js index 6f8c562..4479f96 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,20 +17,6 @@ const config = { plugins: ['@typescript-eslint', 'import'], rules: { '@typescript-eslint/no-namespace': 'off', - '@typescript-eslint/ban-types': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, - ], - '@typescript-eslint/consistent-type-imports': [ - 'warn', - { prefer: 'type-imports', fixStyle: 'separate-type-imports' }, - ], - '@typescript-eslint/no-misused-promises': [ - 2, - { checksVoidReturn: { attributes: false } }, - ], - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], }, ignorePatterns: [ '**/.eslintrc.js', diff --git a/packages/core/package.json b/packages/core/package.json index a0aa5a3..d79a2c0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "next-ws", - "version": "1.0.0", + "version": "1.0.1-experimental.2", "description": "Add support for WebSockets in Next.js 13 app directory", "keywords": [ "next", @@ -48,7 +48,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "rimraf": "^5.0.1", - "typescript": "<5.1.0" + "typescript": "<5.1.0", + "ws": "^8.14.2" }, "eslintConfig": { "root": true, diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 6664679..13f8994 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -1,3 +1,7 @@ -export * from './utilities/patch'; -export type { SocketHandler } from './utilities/ws'; export * from './setup'; +export * from './utilities/patch'; +export { + SocketHandler, + CustomHttpServer, + CustomWsServer, +} from './utilities/server'; diff --git a/packages/core/src/server/setup.ts b/packages/core/src/server/setup.ts index b15d3cc..9ab77d4 100644 --- a/packages/core/src/server/setup.ts +++ b/packages/core/src/server/setup.ts @@ -1,39 +1,39 @@ import * as Log from 'next/dist/build/output/log'; -import type NextNodeServer from 'next/dist/server/next-server'; -import { - getHttpServer, - getPageModule, - resolvePathname, -} from './utilities/next'; -import { getWsServer } from './utilities/ws'; +import NextNodeServer from 'next/dist/server/next-server'; +import { getPageModule, resolvePathname } from './utilities/next'; +import { useHttpServer, useWsServer } from './utilities/server'; export function setupWebSocketServer(nextServer: NextNodeServer) { - const httpServer = getHttpServer(nextServer); - const wsServer = getWsServer(); - Log.ready('[next-ws] websocket server started successfully'); + const httpServer = useHttpServer(nextServer); + const wsServer = useWsServer(); + Log.ready('[next-ws] has started the WebSocket server'); // eslint-disable-next-line @typescript-eslint/no-misused-promises httpServer.on('upgrade', async (request, socket, head) => { const url = new URL(request.url ?? '', 'ws://next'); const pathname = url.pathname; + + // Ignore Next.js internal requests (aka HMR) if (pathname.startsWith('/_next')) return; + // Resolve the pathname to a file system path (eg /about -> /about/route) const fsPathname = resolvePathname(nextServer, pathname); if (!fsPathname) { - Log.error(`[next-ws] could not find module for page ${pathname}`); - return socket.destroy(); + Log.error('[next-ws] could not resolve ${pathname} to a route'); + return socket.end(); } + // Get the page module for the pathname (aka require('/about/route')) const pageModule = await getPageModule(nextServer, fsPathname); if (!pageModule) { - Log.error(`[next-ws] could not find module for page ${pathname}`); - return socket.destroy(); + Log.error('[next-ws] could not find module for page ${pathname}'); + return socket.end(); } const socketHandler = pageModule?.routeModule?.userland?.SOCKET; if (!socketHandler || typeof socketHandler !== 'function') { - Log.error(`[next-ws] ${pathname} does not export a SOCKET handler`); - return socket.destroy(); + Log.error('[next-ws] ${pathname} does not export a SOCKET handler'); + return socket.end(); } return wsServer.handleUpgrade( @@ -48,5 +48,8 @@ export function setupWebSocketServer(nextServer: NextNodeServer) { // Next WS versions below 0.2.0 used a different method of setup // This remains for backwards compatibility, but may be removed in a future version export function hookNextNodeServer(this: NextNodeServer) { + Log.warnOnce( + '[next-ws] is using a deprecated method of hooking into Next.js, this may break in future versions' + ); setupWebSocketServer(this); } diff --git a/packages/core/src/server/utilities/next.ts b/packages/core/src/server/utilities/next.ts index 1a500dc..f253654 100644 --- a/packages/core/src/server/utilities/next.ts +++ b/packages/core/src/server/utilities/next.ts @@ -1,34 +1,15 @@ -import { Server } from 'node:http'; import * as Log from 'next/dist/build/output/log'; import NextNodeServer from 'next/dist/server/next-server'; -import type { SocketHandler } from './ws'; /** - * Get the http.Server instance from the NextNodeServer. - * @param nextServer The NextNodeServer instance. - * @returns The http.Server instance. - */ -export function getHttpServer(nextServer: NextNodeServer) { - if (!nextServer || !(nextServer instanceof NextNodeServer)) - throw new Error('Next WS is missing access to the NextNodeServer'); - - // @ts-expect-error - serverOptions is protected - const httpServer = nextServer.serverOptions?.httpServer; - if (!httpServer || !(httpServer instanceof Server)) - throw new Error('Next WS is missing access to the http.Server'); - - return httpServer; -} - -/** - * Resolve a pathname to a page. + * Resolve a pathname to a page, or null if the page could not be resolved. + * @example resolvePathname(nextServer, '/about') // '/about/route' + * @example resolvePathname(nextServer, '/user/1') // '/user/[id]/route' * @param nextServer The NextNodeServer instance. * @param pathname The pathname to resolve. * @returns The resolved page, or null if the page could not be resolved. */ export function resolvePathname(nextServer: NextNodeServer, pathname: string) { - if (pathname.startsWith('/_next')) return null; - const pathParts = pathname.split('/'); const appRoutes = { // @ts-expect-error - appPathRoutes is protected @@ -77,27 +58,28 @@ export async function getPageModule( nextServer: NextNodeServer, filename: string ) { - try { - // In Next.js 14, hotReloader was removed and ensurePage was moved to NextNodeServer - if ('hotReloader' in nextServer) { - // @ts-expect-error - hotReloader only exists in Next.js 13 - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await nextServer.hotReloader?.ensurePage({ - page: filename, - clientOnly: false, - }); - } else if ('ensurePage' in nextServer) { - // ensurePage throws an error in production, so we need to catch it - // @ts-expect-error - ensurePage is protected - await nextServer.ensurePage({ page: filename, clientOnly: false }); - } else { - // Future-proofing - Log.warnOnce( + if (process.env['NODE_ENV'] !== 'production') { + try { + if ('hotReloader' in nextServer) { + // @ts-expect-error - hotReloader only exists in Next.js 13 + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await nextServer.hotReloader.ensurePage({ + page: filename, + clientOnly: false, + }); + } else if ('ensurePage' in nextServer) { + // @ts-expect-error - ensurePage is protected + await nextServer.ensurePage({ page: filename, clientOnly: false }); + } else { + Log.warnOnce( + '[next-ws] cannot find a way to ensure page, you may need to open routes in your browser first so Next.js compiles them' + ); + } + } catch (error) { + Log.error( '[next-ws] was unable to ensure page, you may need to open the route in your browser first so Next.js compiles it' ); } - } catch { - void 0; } // @ts-expect-error - getPagePath is protected @@ -109,7 +91,7 @@ export async function getPageModule( export interface PageModule { routeModule?: { userland?: { - SOCKET?: SocketHandler; + SOCKET?: unknown; }; }; } diff --git a/packages/core/src/server/utilities/server.ts b/packages/core/src/server/utilities/server.ts new file mode 100644 index 0000000..b013822 --- /dev/null +++ b/packages/core/src/server/utilities/server.ts @@ -0,0 +1,77 @@ +import { Server } from 'node:http'; +import * as Log from 'next/dist/build/output/log'; +import NextNodeServer from 'next/dist/server/next-server'; +import { WebSocketServer } from 'ws'; + +// =============== HTTP Server =============== + +export const CustomHttpServer = 'NextWS::CustomHttpServer'; + +/** + * Get the HTTP Server instance from the NextNodeServer. + * @param nextServer The NextNodeServer instance. + * @returns The HTTP Server instance. + */ +export function useHttpServer(nextServer: NextNodeServer) { + // NextNodeServer is always required, so check for it before attempting to use custom server + if (!nextServer || !(nextServer instanceof NextNodeServer)) { + Log.error('[next-ws] could not find the NextNodeServer instance'); + process.exit(1); + } + + const existing = Reflect.get(globalThis, CustomHttpServer) as Server; + if (existing) { + Log.warnOnce( + '[next-ws] is using a custom HTTP Server, this is experimental and may not work as expected' + ); + return existing; + } + + // @ts-expect-error - serverOptions is protected + const httpServer = nextServer.serverOptions?.httpServer; + if (!httpServer || !(httpServer instanceof Server)) { + Log.error('[next-ws] could not find the HTTP Server instance'); + process.exit(1); + } + + return httpServer; +} + +// =============== WebSocket Server =============== + +export const CustomWsServer = 'NextWS::CustomWsServer'; + +/** + * Create a WebSocketServer. + * @returns The WebSocketServer instance. + */ +export function useWsServer() { + const existing = Reflect.get(globalThis, CustomWsServer) as WebSocketServer; + if (existing) { + Log.warnOnce( + '[next-ws] is using a custom WebSocketServer, this is experimental and may not work as expected' + ); + return existing; + } + + return new WebSocketServer({ noServer: true }); +} + +/** A function that handles a WebSocket connection. */ +export type SocketHandler = ( + /** The WebSocket client that connected. */ + client: import('ws').WebSocket, + /** The HTTP request that initiated the WebSocket connection. */ + request: import('http').IncomingMessage, + /** The WebSocket server. */ + server: import('ws').WebSocketServer +) => unknown; + +declare global { + export namespace NodeJS { + interface Global { + [CustomHttpServer]: import('node:http').Server; + [CustomWsServer]: import('ws').WebSocketServer; + } + } +} diff --git a/packages/core/src/server/utilities/ws.ts b/packages/core/src/server/utilities/ws.ts deleted file mode 100644 index 3f06e5c..0000000 --- a/packages/core/src/server/utilities/ws.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ -import { WebSocketServer } from 'ws'; - -/** A function that handles a WebSocket connection. */ -export type SocketHandler = ( - /** The WebSocket client that connected. */ - client: import('ws').WebSocket, - /** The HTTP request that initiated the WebSocket connection. */ - request: import('http').IncomingMessage, - /** The WebSocket server. */ - server: import('ws').WebSocketServer -) => unknown; - -/** - * Get the WebSocketServer instance. - * @returns The WebSocketServer instance. - */ -export function getWsServer() { - return new WebSocketServer({ noServer: true }); -} diff --git a/yarn.lock b/yarn.lock index 728eefc..9f3eb6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2758,6 +2758,7 @@ __metadata: react-dom: ^18.2.0 rimraf: ^5.0.1 typescript: <5.1.0 + ws: ^8.14.2 peerDependencies: next: ">=13.1.1" react: "*" @@ -3997,6 +3998,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.14.2": + version: 8.14.2 + resolution: "ws@npm:8.14.2" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 3ca0dad26e8cc6515ff392b622a1467430814c463b3368b0258e33696b1d4bed7510bc7030f7b72838b9fdeb8dbd8839cbf808367d6aae2e1d668ce741d4308b + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1"