Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom server #9

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -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';
35 changes: 19 additions & 16 deletions packages/core/src/server/setup.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
}
64 changes: 23 additions & 41 deletions packages/core/src/server/utilities/next.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -109,7 +91,7 @@ export async function getPageModule(
export interface PageModule {
routeModule?: {
userland?: {
SOCKET?: SocketHandler;
SOCKET?: unknown;
};
};
}
77 changes: 77 additions & 0 deletions packages/core/src/server/utilities/server.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
20 changes: 0 additions & 20 deletions packages/core/src/server/utilities/ws.ts

This file was deleted.

16 changes: 16 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*"
Expand Down Expand Up @@ -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"
Expand Down