diff --git a/README.md b/README.md index 8abad35..1d65f75 100644 --- a/README.md +++ b/README.md @@ -46,55 +46,59 @@ In development, to simplify the setup and remove the need to a reverse proxy lik ## Setup Next.js URLs (Development Environment) -If you're serving your site under ASGI during development, -use [Django Channels](https://channels.readthedocs.io/en/stable/) and -add `NextJSProxyHttpConsumer`, `NextJSProxyWebsocketConsumer` to `asgi.py` like the following example. - -**Note:** We recommend using ASGI and Django Channels, -because it is required for [fast refresh](https://nextjs.org/docs/architecture/fast-refresh) (hot module replacement) to work properly in Nextjs 12+. +Configure your `asgi.py` with `NextJsMiddleware` as shown below: ```python import os from django.core.asgi import get_asgi_application -from django.urls import re_path, path os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") django_asgi_app = get_asgi_application() -from channels.auth import AuthMiddlewareStack -from channels.routing import ProtocolTypeRouter, URLRouter -from django_nextjs.proxy import NextJSProxyHttpConsumer, NextJSProxyWebsocketConsumer - -from django.conf import settings +from django_nextjs.asgi import NextJsMiddleware -# put your custom routes here if you need -http_routes = [re_path(r"", django_asgi_app)] -websocket_routers = [] +application = NextJsMiddleware(django_asgi_app) +``` -if settings.DEBUG: - http_routes.insert(0, re_path(r"^(?:_next|__next|next).*", NextJSProxyHttpConsumer.as_asgi())) - websocket_routers.insert(0, path("_next/webpack-hmr", NextJSProxyWebsocketConsumer.as_asgi())) +The middleware automatically handles routing for Next.js assets and API requests, and supports WebSocket connections for fast refresh to work properly. +You can use `NextJsMiddleware` with any ASGI application. +For example, you can use it with `ProtocolTypeRouter` +if you are using [Django Channels](https://channels.readthedocs.io/en/latest/): -application = ProtocolTypeRouter( - { - # Django's ASGI application to handle traditional HTTP and websocket requests. - "http": URLRouter(http_routes), - "websocket": AuthMiddlewareStack(URLRouter(websocket_routers)), - # ... - } +```python +application = NextJsMiddleware( + ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": my_websocket_handler, + # ... + } + ) ) ``` -Otherwise (if serving under WSGI during development), add the following to the beginning of `urls.py`: +If you're not using ASGI, add the following path to the beginning of `urls.py`: ```python -path("", include("django_nextjs.urls")) +urlpatterns = [ + path("", include("django_nextjs.urls")), + ... +] ``` -**Warning:** If you are serving under ASGI, do NOT add this -to your `urls.py`. It may cause deadlocks. +> [!IMPORTANT] +> Using ASGI is **required** +> for [fast refresh](https://nextjs.org/docs/architecture/fast-refresh) +> to work properly in Next.js 12+. +> Without it, you'll need to manually refresh your browser +> to see changes during development. +> +> To run your ASGI application, you can use an ASGI server +> such as [Daphne](https://github.com/django/daphne) +> or [Uvicorn](https://www.uvicorn.org/). + ## Setup Next.js URLs (Production Environment) @@ -273,7 +277,7 @@ urlpatterns = [ - If you want to add a file to `public` directory of Next.js, that file should be in `public/next` subdirectory to work correctly. -- If you're using Django channels, make sure all your middlewares are +- If you're using ASGI, make sure all your middlewares are [async-capable](https://docs.djangoproject.com/en/dev/topics/http/middleware/#asynchronous-support). - To avoid "Too many redirects" error, you may need to add `APPEND_SLASH = False` in your Django project's `settings.py`. Also, do not add `/` at the end of nextjs paths in `urls.py`. - This package does not provide a solution for passing data from Django to Next.js. The Django Rest Framework, GraphQL, or similar solutions should still be used. @@ -287,6 +291,7 @@ Default settings: NEXTJS_SETTINGS = { "nextjs_server_url": "http://127.0.0.1:3000", "ensure_csrf_token": True, + "dev_proxy_paths": ["/_next", "/__next", "/next"], } ``` @@ -304,6 +309,15 @@ You may need to issue GraphQL POST requests to fetch data in Next.js `getServerS In this case this option solves the issue, and as long as `getServerSideProps` functions are side-effect free (i.e., they don't use HTTP unsafe methods or GraphQL mutations), it should be fine from a security perspective. Read more [here](https://docs.djangoproject.com/en/3.2/ref/csrf/#is-posting-an-arbitrary-csrf-token-pair-cookie-and-post-data-a-vulnerability). +### `dev_proxy_paths` + +A list of paths that should be proxied to the Next.js server in development mode. + +This is useful if you want to use a custom path instead of `/next` inside the `public` directory of Next.js. +For example, if you want to use `/static-next` instead of `/next`, you can set `proxy_paths` to `["/_next", "/__next", "/static-next"]` +and place your static files in `public/static-next` directory of Next.js. +You should also update the production reverse proxy configuration accordingly. + ## Contributing To start development: diff --git a/django_nextjs/app_settings.py b/django_nextjs/app_settings.py index c641030..c659153 100644 --- a/django_nextjs/app_settings.py +++ b/django_nextjs/app_settings.py @@ -5,5 +5,5 @@ NEXTJS_SETTINGS = getattr(settings, "NEXTJS_SETTINGS", {}) NEXTJS_SERVER_URL = NEXTJS_SETTINGS.get("nextjs_server_url", "http://127.0.0.1:3000") - ENSURE_CSRF_TOKEN = NEXTJS_SETTINGS.get("ensure_csrf_token", True) +DEV_PROXY_PATHS = NEXTJS_SETTINGS.get("dev_proxy_paths", ["/_next", "/__next", "/next"]) diff --git a/django_nextjs/asgi.py b/django_nextjs/asgi.py new file mode 100644 index 0000000..846aef6 --- /dev/null +++ b/django_nextjs/asgi.py @@ -0,0 +1,276 @@ +import asyncio +import functools +import typing +from abc import ABC, abstractmethod +from typing import Optional +from urllib.parse import urlparse + +import aiohttp +import websockets +from django.conf import settings +from websockets import Data +from websockets.asyncio.client import ClientConnection + +from django_nextjs.app_settings import DEV_PROXY_PATHS, NEXTJS_SERVER_URL +from django_nextjs.exceptions import NextJsImproperlyConfigured + +# https://github.com/encode/starlette/blob/b9db010d49cfa33d453facde56e53a621325c720/starlette/types.py +Scope = typing.MutableMapping[str, typing.Any] +Message = typing.MutableMapping[str, typing.Any] +Receive = typing.Callable[[], typing.Awaitable[Message]] +Send = typing.Callable[[Message], typing.Awaitable[None]] +ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] + + +class StopReceiving(Exception): + pass + + +class NextJsProxyBase(ABC): + scope: Scope + send: Send + + def __init__(self): + if not settings.DEBUG: + raise NextJsImproperlyConfigured("This proxy is for development only.") + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + self.scope = scope + self.send = send + + while True: + message = await receive() + try: + await self.handle_message(message) + except StopReceiving: + return # Exit cleanly + + @abstractmethod + async def handle_message(self, message: Message): ... + + @classmethod + def as_asgi(cls): + """ + Return an ASGI v3 single callable that instantiates a consumer instance per scope. + Similar in purpose to Django's as_view(). + """ + + async def app(scope: Scope, receive: Receive, send: Send): + consumer = cls() + return await consumer(scope, receive, send) + + # take name and docstring from class + functools.update_wrapper(app, cls, updated=()) + return app + + +class NextJsHttpProxy(NextJsProxyBase): + """ + Manages HTTP requests and proxies them to the Next.js development server. + + This handler is responsible for forwarding HTTP requests received by the + Django application to the Next.js development server. It ensures that + headers and body content are correctly relayed, and the response from + the Next.js server is streamed back to the client. This is primarily + used in development to serve Next.js assets through Django's ASGI server. + """ + + def __init__(self): + super().__init__() + self.body = [] + + async def handle_message(self, message: Message) -> None: + if message["type"] == "http.request": + self.body.append(message.get("body", b"")) + if not message.get("more_body", False): + await self.handle_request(b"".join(self.body)) + elif message["type"] == "http.disconnect": + raise StopReceiving + + async def handle_request(self, body: bytes): + url = NEXTJS_SERVER_URL + self.scope["path"] + "?" + self.scope["query_string"].decode() + headers = {k.decode(): v.decode() for k, v in self.scope["headers"]} + + if session := self.scope.get("state", {}).get(NextJsMiddleware.HTTP_SESSION_KEY): + session_is_temporary = False + else: + # If the shared session is not available, we create a temporary session. + # This is typically the case when the ASGI server does not support the lifespan protocol (e.g. Daphne). + session = aiohttp.ClientSession() + session_is_temporary = True + + try: + async with session.get(url, data=body, headers=headers) as response: + nextjs_response_headers = [ + (name.encode(), value.encode()) + for name, value in response.headers.items() + if name.lower() in ["content-type", "set-cookie"] + ] + + await self.send( + {"type": "http.response.start", "status": response.status, "headers": nextjs_response_headers} + ) + async for data in response.content.iter_any(): + await self.send({"type": "http.response.body", "body": data, "more_body": True}) + await self.send({"type": "http.response.body", "body": b"", "more_body": False}) + finally: + if session_is_temporary: + await session.close() + + +class NextJsWebSocketProxy(NextJsProxyBase): + """ + Manages WebSocket connections and proxies messages between the client (browser) + and the Next.js development server. + + This handler is essential for enabling real-time features like Hot Module + Replacement (HMR) during development. It establishes a WebSocket connection + to the Next.js server and relays messages back and forth, allowing for + seamless updates in the browser when code changes are detected. + """ + + nextjs_connection: Optional[ClientConnection] + nextjs_listener_task: Optional[asyncio.Task] + + def __init__(self): + super().__init__() + self.nextjs_connection = None + self.nextjs_listener_task = None + + async def handle_message(self, message: Message) -> None: + if message["type"] == "websocket.connect": + await self.connect() + elif message["type"] == "websocket.receive": + if not self.nextjs_connection: + await self.send({"type": "websocket.close"}) + elif data := message.get("text", message.get("bytes")): + await self.receive(self.nextjs_connection, data=data) + elif message["type"] == "websocket.disconnect": + await self.disconnect() + raise StopReceiving + + async def connect(self): + nextjs_websocket_url = f"ws://{urlparse(NEXTJS_SERVER_URL).netloc}{self.scope['path']}" + try: + self.nextjs_connection = await websockets.connect(nextjs_websocket_url) + except: + await self.send({"type": "websocket.close"}) + raise + self.nextjs_listener_task = asyncio.create_task(self._receive_from_nextjs_server(self.nextjs_connection)) + await self.send({"type": "websocket.accept"}) + + async def _receive_from_nextjs_server(self, nextjs_connection: ClientConnection): + """ + Listens for messages from the Next.js development server and forwards them to the browser. + """ + try: + async for message in nextjs_connection: + if isinstance(message, bytes): + await self.send({"type": "websocket.send", "bytes": message}) + elif isinstance(message, str): + await self.send({"type": "websocket.send", "text": message}) + except websockets.ConnectionClosedError: + await self.send({"type": "websocket.close"}) + + async def receive(self, nextjs_connection: ClientConnection, data: Data): + """ + Handles incoming messages from the browser and forwards them to the Next.js development server. + """ + try: + await nextjs_connection.send(data) + except websockets.ConnectionClosed: + await self.send({"type": "websocket.close"}) + + async def disconnect(self): + """ + Performs cleanup when the WebSocket connection is closed, either by the browser or by us. + """ + + if self.nextjs_listener_task: + self.nextjs_listener_task.cancel() + self.nextjs_listener_task = None + + if self.nextjs_connection: + await self.nextjs_connection.close() + self.nextjs_connection = None + + +class NextJsMiddleware: + """ + ASGI middleware that integrates Django and Next.js applications. + + - Intercepts requests to Next.js paths (like '/_next', '/__next', '/next') in development + mode and forwards them to the Next.js development server. This works as a transparent + proxy, handling both HTTP requests and WebSocket connections (for Hot Module Replacement). + + - Manages an aiohttp ClientSession throughout the application lifecycle using the ASGI + lifespan protocol. The session is created during application startup and properly closed + during shutdown, ensuring efficient reuse of HTTP connections when communicating with the + Next.js server. + """ + + HTTP_SESSION_KEY = "django_nextjs_http_session" + + def __init__(self, inner_app: ASGIApp) -> None: + self.inner_app = inner_app + + if settings.DEBUG: + # Pre-create ASGI callables for the consumers + self.nextjs_http_proxy = NextJsHttpProxy.as_asgi() + self.nextjs_websocket_proxy = NextJsWebSocketProxy.as_asgi() + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + + # --- Lifespan Handling --- + if scope["type"] == "lifespan": + # Handle lifespan events (startup/shutdown) + return await self._handle_lifespan(scope, receive, send) + + # --- Next.js Route Handling (DEBUG mode only) --- + elif settings.DEBUG: + path = scope.get("path", "") + if any(path.startswith(prefix) for prefix in DEV_PROXY_PATHS): + if scope["type"] == "http": + return await self.nextjs_http_proxy(scope, receive, send) + elif scope["type"] == "websocket": + return await self.nextjs_websocket_proxy(scope, receive, send) + + # --- Default Handling --- + return await self.inner_app(scope, receive, send) + + async def _handle_lifespan(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + Handle the lifespan protocol for the ASGI application. + This is where we can manage the lifecycle of the application. + + https://asgi.readthedocs.io/en/latest/specs/lifespan.html + """ + + async def lifespan_receive() -> Message: + message = await receive() + if message["type"] == "lifespan.startup" and "state" in scope: + # Create a new aiohttp ClientSession and store it in the scope's state. + # This session will be used for making HTTP requests to the Next.js server + # during the application's lifetime. + scope["state"][self.HTTP_SESSION_KEY] = aiohttp.ClientSession() + return message + + async def lifespan_send(message: Message) -> None: + if message["type"] == "lifespan.shutdown.complete" and "state" in scope: + # Clean up resources after inner app shutdown is complete + http_session: typing.Optional[aiohttp.ClientSession] = scope["state"].get(self.HTTP_SESSION_KEY) + if http_session: + await http_session.close() + await send(message) + + try: + await self.inner_app(scope, lifespan_receive, lifespan_send) + except: + # The underlying app has not implemented the lifespan protocol, so we run our own implementation. + while True: + lifespan_message = await lifespan_receive() + if lifespan_message["type"] == "lifespan.startup": + await lifespan_send({"type": "lifespan.startup.complete"}) + elif lifespan_message["type"] == "lifespan.shutdown": + await lifespan_send({"type": "lifespan.shutdown.complete"}) + return diff --git a/django_nextjs/exceptions.py b/django_nextjs/exceptions.py index 9352bde..62f039b 100644 --- a/django_nextjs/exceptions.py +++ b/django_nextjs/exceptions.py @@ -1,2 +1,2 @@ -class NextJSImproperlyConfigured(Exception): +class NextJsImproperlyConfigured(Exception): pass diff --git a/django_nextjs/proxy.py b/django_nextjs/proxy.py index a1f0bec..c06b093 100644 --- a/django_nextjs/proxy.py +++ b/django_nextjs/proxy.py @@ -1,125 +1,40 @@ -import asyncio +import logging import urllib.request from http.client import HTTPResponse -from typing import Optional -from urllib.parse import urlparse -import aiohttp -import websockets -from channels.generic.http import AsyncHttpConsumer -from channels.generic.websocket import AsyncWebsocketConsumer from django import http from django.conf import settings from django.views import View -from websockets.asyncio.client import ClientConnection from django_nextjs.app_settings import NEXTJS_SERVER_URL -from django_nextjs.exceptions import NextJSImproperlyConfigured +from django_nextjs.asgi import NextJsHttpProxy, NextJsWebSocketProxy +from django_nextjs.exceptions import NextJsImproperlyConfigured +logger = logging.getLogger(__name__) -class NextJSProxyHttpConsumer(AsyncHttpConsumer): - """ - Proxies /next..., /_next..., /__nextjs... requests to Next.js server in development environment. - - - This is an async consumer for django channels. - - Supports streaming response. - """ - - async def handle(self, body): - if not settings.DEBUG: - raise NextJSImproperlyConfigured("This proxy is for development only.") - url = NEXTJS_SERVER_URL + self.scope["path"] + "?" + self.scope["query_string"].decode() - headers = {k.decode(): v.decode() for k, v in self.scope["headers"]} - async with aiohttp.ClientSession(headers=headers) as session: - async with session.get(url) as response: - nextjs_response_headers = [ - (name.encode(), value.encode()) - for name, value in response.headers.items() - if name.lower() in ["content-type", "set-cookie"] - ] - - await self.send_headers(status=response.status, headers=nextjs_response_headers) - async for data in response.content.iter_any(): - await self.send_body(data, more_body=True) - await self.send_body(b"", more_body=False) - - -class NextJSProxyWebsocketConsumer(AsyncWebsocketConsumer): - """ - Manages WebSocket connections and proxies messages between the client (browser) - and the Next.js development server. - This consumer is essential for enabling real-time features like Hot Module - Replacement (HMR) during development. It establishes a WebSocket connection - to the Next.js server and relays messages back and forth, allowing for - seamless updates in the browser when code changes are detected. - - Note: This consumer is intended for use in development environments only. - """ - - nextjs_connection: Optional[ClientConnection] - nextjs_listener_task: Optional[asyncio.Task] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not settings.DEBUG: - raise NextJSImproperlyConfigured("This proxy is for development only.") - self.nextjs_connection = None - self.nextjs_listener_task = None - - async def connect(self): - nextjs_websocket_url = f"ws://{urlparse(NEXTJS_SERVER_URL).netloc}{self.scope['path']}" - try: - self.nextjs_connection = await websockets.connect(nextjs_websocket_url) - except: - await self.close() - raise - self.nextjs_listener_task = asyncio.create_task(self._receive_from_nextjs_server()) - await self.accept() - - async def _receive_from_nextjs_server(self): - """ - Listens for messages from the Next.js development server and forwards them to the browser. - """ - if not self.nextjs_connection: - await self.close() - return - try: - async for message in self.nextjs_connection: - if isinstance(message, bytes): - await self.send(bytes_data=message) - elif isinstance(message, str): - await self.send(text_data=message) - except websockets.ConnectionClosedError: - await self.close() - - async def receive(self, text_data=None, bytes_data=None): - """ - Handles incoming messages from the browser and forwards them to the Next.js development server. - """ - data = text_data or bytes_data - if not data: - return - if not self.nextjs_connection: - await self.close() - return - try: - await self.nextjs_connection.send(data) - except websockets.ConnectionClosed: - await self.close() - - async def disconnect(self, code): - """ - Performs cleanup when the WebSocket connection is closed, either by the browser or by us. - """ +class NextJSProxyHttpConsumer(NextJsHttpProxy): + @classmethod + def as_asgi(cls): + # Use "logging" instead of "warnings" module because of this issue: + # https://github.com/django/daphne/issues/352 + logger.warning( + "NextJSProxyHttpConsumer is deprecated and will be removed in the next major release. " + "Use NextJsMiddleware from django_nextjs.asgi instead.", + ) + return super().as_asgi() - if self.nextjs_listener_task: - self.nextjs_listener_task.cancel() - self.nextjs_listener_task = None - if self.nextjs_connection: - await self.nextjs_connection.close() - self.nextjs_connection = None +class NextJSProxyWebsocketConsumer(NextJsWebSocketProxy): + @classmethod + def as_asgi(cls): + # Use "logging" instead of "warnings" module because of this issue: + # https://github.com/django/daphne/issues/352 + logger.warning( + "NextJSProxyWebsocketConsumer is deprecated and will be removed in the next major release. " + "Use NextJsMiddleware from django_nextjs.asgi instead.", + ) + return super().as_asgi() class NextJSProxyView(View): @@ -133,7 +48,7 @@ class NextJSProxyView(View): def dispatch(self, request, *args, **kwargs): if not settings.DEBUG: - raise NextJSImproperlyConfigured("This proxy is for development only.") + raise NextJsImproperlyConfigured("This proxy is for development only.") return super().dispatch(request, *args, **kwargs) def get(self, request): diff --git a/django_nextjs/render.py b/django_nextjs/render.py index c515d07..50b1385 100644 --- a/django_nextjs/render.py +++ b/django_nextjs/render.py @@ -5,12 +5,14 @@ import aiohttp from asgiref.sync import sync_to_async from django.conf import settings +from django.core.handlers.asgi import ASGIRequest from django.http import HttpRequest, HttpResponse, StreamingHttpResponse from django.middleware.csrf import get_token as get_csrf_token from django.template.loader import render_to_string from multidict import MultiMapping from .app_settings import ENSURE_CSRF_TOKEN, NEXTJS_SERVER_URL +from .asgi import NextJsMiddleware from .utils import filter_mapping_obj morsel = Morsel() @@ -48,7 +50,7 @@ def _get_nextjs_request_cookies(request: HttpRequest): def _get_nextjs_request_headers(request: HttpRequest, headers: Optional[dict] = None): - # These headers are used by NextJS to indicate if a request is expecting a full HTML + # These headers are used by Next.js to indicate if a request is expecting a full HTML # response, or an RSC response. server_component_headers = filter_mapping_obj( request.headers, @@ -158,7 +160,7 @@ async def render_nextjs_page( async def stream_nextjs_page( - request: HttpRequest, + request: ASGIRequest, allow_redirects: bool = False, headers: Optional[dict] = None, ): @@ -170,7 +172,13 @@ async def stream_nextjs_page( params = [(k, v) for k in request.GET.keys() for v in request.GET.getlist(k)] next_url = f"{NEXTJS_SERVER_URL}/{page_path}" - session = aiohttp.ClientSession() + if session := request.scope.get("state", {}).get(NextJsMiddleware.HTTP_SESSION_KEY): + session_is_temporary = False + else: + # If the shared session is not available, we create a temporary session. + # This is typically the case when the ASGI server does not support the lifespan protocol (e.g. Daphne). + session = aiohttp.ClientSession() + session_is_temporary = True try: nextjs_response = await session.get( @@ -188,7 +196,8 @@ async def stream_nextjs_response(): yield chunk finally: await nextjs_response.release() - await session.close() + if session_is_temporary: + await session.close() return StreamingHttpResponse( stream_nextjs_response(), @@ -196,5 +205,6 @@ async def stream_nextjs_response(): headers=response_headers, ) except: - await session.close() + if session_is_temporary: + await session.close() raise diff --git a/setup.py b/setup.py index 5f71021..bd8a4cb 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ download_url="https://github.com/QueraTeam/django-nextjs", packages=find_packages(".", include=("django_nextjs", "django_nextjs.*")), include_package_data=True, - install_requires=["Django >= 4.2", "aiohttp", "channels", "websockets"], + install_requires=["Django >= 4.2", "aiohttp", "websockets"], extras_require={"dev": dev_requirements}, classifiers=[ "Development Status :: 5 - Production/Stable",