From 9d8e30e02f7dc2e440f5dd3d21fec323053710ec Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Sat, 17 May 2025 14:30:55 +0330 Subject: [PATCH 1/6] Add DjangoNextjsASGIMiddleware --- README.md | 39 +++++++---------- django_nextjs/asgi.py | 93 +++++++++++++++++++++++++++++++++++++++++ django_nextjs/proxy.py | 18 +++++++- django_nextjs/render.py | 18 ++++++-- 4 files changed, 137 insertions(+), 31 deletions(-) create mode 100644 django_nextjs/asgi.py diff --git a/README.md b/README.md index 8abad35..c85b2e1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ In development, to simplify the setup and remove the need to a reverse proxy lik 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. +use `DjangoNextjsASGIMiddleware` 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+. @@ -56,34 +56,23 @@ because it is required for [fast refresh](https://nextjs.org/docs/architecture/f ```python import os +from channels.routing import ProtocolTypeRouter from django.core.asgi import get_asgi_application -from django.urls import re_path, path -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.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 - -# put your custom routes here if you need -http_routes = [re_path(r"", django_asgi_app)] -websocket_routers = [] - -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())) - - -application = ProtocolTypeRouter( - { - # Django's ASGI application to handle traditional HTTP and websocket requests. - "http": URLRouter(http_routes), - "websocket": AuthMiddlewareStack(URLRouter(websocket_routers)), - # ... - } +from django_nextjs.asgi import DjangoNextjsASGIMiddleware + +application = DjangoNextjsASGIMiddleware( + ProtocolTypeRouter( + { + # Django's ASGI application to handle traditional HTTP requests. + "http": django_asgi_app, + # "websocket": ... + # ... + } + ) ) ``` diff --git a/django_nextjs/asgi.py b/django_nextjs/asgi.py new file mode 100644 index 0000000..351f6ee --- /dev/null +++ b/django_nextjs/asgi.py @@ -0,0 +1,93 @@ +import typing + +import aiohttp +from django.conf import settings + +# 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 DjangoNextjsASGIMiddleware: + """ + 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, *, nextjs_proxy_paths: typing.Optional[list[str]] = None) -> None: + from django_nextjs.proxy import NextJSProxyHttpConsumer, NextJSProxyWebsocketConsumer + + self.inner_app = inner_app + self.nextjs_proxy_paths: list[str] = nextjs_proxy_paths or ["/_next", "/__next", "/next"] + # Pre-create ASGI callables for the consumers + self.nextjs_proxy_http_consumer = NextJSProxyHttpConsumer.as_asgi() + self.nextjs_proxy_ws_consumer = NextJSProxyWebsocketConsumer.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 self.nextjs_proxy_paths): + if scope["type"] == "http": + return await self.nextjs_proxy_http_consumer(scope, receive, send) + elif scope["type"] == "websocket": + return await self.nextjs_proxy_ws_consumer(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/proxy.py b/django_nextjs/proxy.py index a1f0bec..a76b898 100644 --- a/django_nextjs/proxy.py +++ b/django_nextjs/proxy.py @@ -14,6 +14,7 @@ from websockets.asyncio.client import ClientConnection from django_nextjs.app_settings import NEXTJS_SERVER_URL +from django_nextjs.asgi import DjangoNextjsASGIMiddleware from django_nextjs.exceptions import NextJSImproperlyConfigured @@ -28,10 +29,20 @@ class NextJSProxyHttpConsumer(AsyncHttpConsumer): 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: + + if session := self.scope.get("state", {}).get(DjangoNextjsASGIMiddleware.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, headers=headers) as response: nextjs_response_headers = [ (name.encode(), value.encode()) for name, value in response.headers.items() @@ -42,6 +53,9 @@ async def handle(self, body): async for data in response.content.iter_any(): await self.send_body(data, more_body=True) await self.send_body(b"", more_body=False) + finally: + if session_is_temporary: + await session.close() class NextJSProxyWebsocketConsumer(AsyncWebsocketConsumer): diff --git a/django_nextjs/render.py b/django_nextjs/render.py index c515d07..c221221 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 DjangoNextjsASGIMiddleware from .utils import filter_mapping_obj morsel = Morsel() @@ -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(DjangoNextjsASGIMiddleware.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 From 30e6239a7ecad39b1a768c20934115f2c510a1e8 Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Sun, 18 May 2025 11:26:15 +0330 Subject: [PATCH 2/6] Enhance README about ASGI usage --- README.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c85b2e1..abea0bf 100644 --- a/README.md +++ b/README.md @@ -46,44 +46,57 @@ 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 -use `DjangoNextjsASGIMiddleware` like the following example. +We **strongly recommend** using ASGI with [Django Channels](https://channels.readthedocs.io/en/stable/), +as this enables critical Next.js features like [fast refresh](https://nextjs.org/docs/architecture/fast-refresh) through WebSocket support. -**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 `DjangoNextjsASGIMiddleware` as shown below: ```python import os -from channels.routing import ProtocolTypeRouter from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") django_asgi_app = get_asgi_application() from django_nextjs.asgi import DjangoNextjsASGIMiddleware +application = DjangoNextjsASGIMiddleware(django_asgi_app) +``` + +The middleware automatically handles routing for Next.js assets and API requests, and supports WebSocket connections for fast refresh to work properly. + +You can also use `DjangoNextjsASGIMiddleware` with any ASGI application. +For example, you can use it with `ProtocolTypeRouter` if you have other protocols: + +```python application = DjangoNextjsASGIMiddleware( ProtocolTypeRouter( { - # Django's ASGI application to handle traditional HTTP requests. "http": django_asgi_app, - # "websocket": ... + "websocket": my_websocket_handler, # ... } ) ) ``` -Otherwise (if serving under WSGI during development), add the following to the beginning of `urls.py`: +Otherwise (if serving under WSGI during development), 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. + ## Setup Next.js URLs (Production Environment) From dddc627baa4ba7783b848087a75cb6cade430fa7 Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Sun, 18 May 2025 19:07:17 +0330 Subject: [PATCH 3/6] Remove dependency to Django Channels - Implement NextJSHttpProxy and NextJSWebsocketProxy as pure ASGI handlers instead of Django Channels consumers. - Deprecate Django Channels consumers. - Remove "channels" from dependencies. --- README.md | 16 ++-- django_nextjs/asgi.py | 201 +++++++++++++++++++++++++++++++++++++++-- django_nextjs/proxy.py | 145 +++++------------------------ setup.py | 2 +- 4 files changed, 226 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index abea0bf..c8a0f7f 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,6 @@ In development, to simplify the setup and remove the need to a reverse proxy lik ## Setup Next.js URLs (Development Environment) -We **strongly recommend** using ASGI with [Django Channels](https://channels.readthedocs.io/en/stable/), -as this enables critical Next.js features like [fast refresh](https://nextjs.org/docs/architecture/fast-refresh) through WebSocket support. - Configure your `asgi.py` with `DjangoNextjsASGIMiddleware` as shown below: ```python @@ -66,8 +63,9 @@ application = DjangoNextjsASGIMiddleware(django_asgi_app) The middleware automatically handles routing for Next.js assets and API requests, and supports WebSocket connections for fast refresh to work properly. -You can also use `DjangoNextjsASGIMiddleware` with any ASGI application. -For example, you can use it with `ProtocolTypeRouter` if you have other protocols: +You can use `DjangoNextjsASGIMiddleware` with any ASGI application. +For example, you can use it with `ProtocolTypeRouter` +if you are using [Django Channels](https://channels.readthedocs.io/en/latest/): ```python application = DjangoNextjsASGIMiddleware( @@ -81,7 +79,7 @@ application = DjangoNextjsASGIMiddleware( ) ``` -Otherwise (if serving under WSGI during development), add the following path to the beginning of `urls.py`: +If you're not using ASGI, add the following path to the beginning of `urls.py`: ```python urlpatterns = [ @@ -96,6 +94,10 @@ urlpatterns = [ > 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) @@ -275,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. diff --git a/django_nextjs/asgi.py b/django_nextjs/asgi.py index 351f6ee..baa783a 100644 --- a/django_nextjs/asgi.py +++ b/django_nextjs/asgi.py @@ -1,7 +1,18 @@ +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 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] @@ -11,6 +22,179 @@ 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, receive, 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(DjangoNextjsASGIMiddleware.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 DjangoNextjsASGIMiddleware: """ ASGI middleware that integrates Django and Next.js applications. @@ -28,13 +212,14 @@ class DjangoNextjsASGIMiddleware: HTTP_SESSION_KEY = "django_nextjs_http_session" def __init__(self, inner_app: ASGIApp, *, nextjs_proxy_paths: typing.Optional[list[str]] = None) -> None: - from django_nextjs.proxy import NextJSProxyHttpConsumer, NextJSProxyWebsocketConsumer - self.inner_app = inner_app - self.nextjs_proxy_paths: list[str] = nextjs_proxy_paths or ["/_next", "/__next", "/next"] - # Pre-create ASGI callables for the consumers - self.nextjs_proxy_http_consumer = NextJSProxyHttpConsumer.as_asgi() - self.nextjs_proxy_ws_consumer = NextJSProxyWebsocketConsumer.as_asgi() + + if settings.DEBUG: + self.nextjs_proxy_paths: list[str] = nextjs_proxy_paths or ["/_next", "/__next", "/next"] + + # 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: @@ -48,9 +233,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: path = scope.get("path", "") if any(path.startswith(prefix) for prefix in self.nextjs_proxy_paths): if scope["type"] == "http": - return await self.nextjs_proxy_http_consumer(scope, receive, send) + return await self.nextjs_http_proxy(scope, receive, send) elif scope["type"] == "websocket": - return await self.nextjs_proxy_ws_consumer(scope, receive, send) + return await self.nextjs_websocket_proxy(scope, receive, send) # --- Default Handling --- return await self.inner_app(scope, receive, send) diff --git a/django_nextjs/proxy.py b/django_nextjs/proxy.py index a76b898..adbecff 100644 --- a/django_nextjs/proxy.py +++ b/django_nextjs/proxy.py @@ -1,139 +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.asgi import DjangoNextjsASGIMiddleware +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"]} - - if session := self.scope.get("state", {}).get(DjangoNextjsASGIMiddleware.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, 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_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) - finally: - if session_is_temporary: - await session.close() - - -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 DjangoNextjsASGIMiddleware 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 DjangoNextjsASGIMiddleware from django_nextjs.asgi instead.", + ) + return super().as_asgi() class NextJSProxyView(View): 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", From 6b1f2370e06dffba2017291b877e3f47213d6750 Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Sun, 18 May 2025 19:29:44 +0330 Subject: [PATCH 4/6] Add dev_proxy_paths setting --- README.md | 10 ++++++++++ django_nextjs/app_settings.py | 2 +- django_nextjs/asgi.py | 8 +++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c8a0f7f..26c2926 100644 --- a/README.md +++ b/README.md @@ -291,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"], } ``` @@ -308,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 index baa783a..4f89818 100644 --- a/django_nextjs/asgi.py +++ b/django_nextjs/asgi.py @@ -11,7 +11,7 @@ from websockets import Data from websockets.asyncio.client import ClientConnection -from django_nextjs.app_settings import NEXTJS_SERVER_URL +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 @@ -211,12 +211,10 @@ class DjangoNextjsASGIMiddleware: HTTP_SESSION_KEY = "django_nextjs_http_session" - def __init__(self, inner_app: ASGIApp, *, nextjs_proxy_paths: typing.Optional[list[str]] = None) -> None: + def __init__(self, inner_app: ASGIApp) -> None: self.inner_app = inner_app if settings.DEBUG: - self.nextjs_proxy_paths: list[str] = nextjs_proxy_paths or ["/_next", "/__next", "/next"] - # Pre-create ASGI callables for the consumers self.nextjs_http_proxy = NextJSHttpProxy.as_asgi() self.nextjs_websocket_proxy = NextJSWebsocketProxy.as_asgi() @@ -231,7 +229,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # --- Next.js Route Handling (DEBUG mode only) --- elif settings.DEBUG: path = scope.get("path", "") - if any(path.startswith(prefix) for prefix in self.nextjs_proxy_paths): + 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": From c457b4135dd3182f9f6aaf23d27d76ac140461d9 Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Mon, 19 May 2025 14:57:08 +0330 Subject: [PATCH 5/6] Use more consistent class names --- README.md | 10 +++++----- django_nextjs/asgi.py | 20 ++++++++++---------- django_nextjs/exceptions.py | 2 +- django_nextjs/proxy.py | 14 +++++++------- django_nextjs/render.py | 6 +++--- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 26c2926..98867bb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ In development, to simplify the setup and remove the need to a reverse proxy lik ## Setup Next.js URLs (Development Environment) -Configure your `asgi.py` with `DjangoNextjsASGIMiddleware` as shown below: +Configure your `asgi.py` with `DjangoNextJsAsgiMiddleware` as shown below: ```python import os @@ -56,19 +56,19 @@ from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") django_asgi_app = get_asgi_application() -from django_nextjs.asgi import DjangoNextjsASGIMiddleware +from django_nextjs.asgi import DjangoNextJsAsgiMiddleware -application = DjangoNextjsASGIMiddleware(django_asgi_app) +application = DjangoNextJsAsgiMiddleware(django_asgi_app) ``` 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 `DjangoNextjsASGIMiddleware` with any ASGI application. +You can use `DjangoNextJsAsgiMiddleware` with any ASGI application. For example, you can use it with `ProtocolTypeRouter` if you are using [Django Channels](https://channels.readthedocs.io/en/latest/): ```python -application = DjangoNextjsASGIMiddleware( +application = DjangoNextJsAsgiMiddleware( ProtocolTypeRouter( { "http": django_asgi_app, diff --git a/django_nextjs/asgi.py b/django_nextjs/asgi.py index 4f89818..6c87cb2 100644 --- a/django_nextjs/asgi.py +++ b/django_nextjs/asgi.py @@ -12,7 +12,7 @@ from websockets.asyncio.client import ClientConnection from django_nextjs.app_settings import DEV_PROXY_PATHS, NEXTJS_SERVER_URL -from django_nextjs.exceptions import NextJSImproperlyConfigured +from django_nextjs.exceptions import NextJsImproperlyConfigured # https://github.com/encode/starlette/blob/b9db010d49cfa33d453facde56e53a621325c720/starlette/types.py Scope = typing.MutableMapping[str, typing.Any] @@ -26,13 +26,13 @@ class StopReceiving(Exception): pass -class NextJSProxyBase(ABC): +class NextJsProxyBase(ABC): scope: Scope send: Send def __init__(self): if not settings.DEBUG: - raise NextJSImproperlyConfigured("This proxy is for development only.") + raise NextJsImproperlyConfigured("This proxy is for development only.") async def __call__(self, scope: Scope, receive: Receive, send: Send): self.scope = scope @@ -55,7 +55,7 @@ def as_asgi(cls): Similar in purpose to Django's as_view(). """ - async def app(scope, receive, send): + async def app(scope: Scope, receive: Receive, send: Send): consumer = cls() return await consumer(scope, receive, send) @@ -64,7 +64,7 @@ async def app(scope, receive, send): return app -class NextJSHttpProxy(NextJSProxyBase): +class NextJsHttpProxy(NextJsProxyBase): """ Manages HTTP requests and proxies them to the Next.js development server. @@ -91,7 +91,7 @@ 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(DjangoNextjsASGIMiddleware.HTTP_SESSION_KEY): + if session := self.scope.get("state", {}).get(DjangoNextJsAsgiMiddleware.HTTP_SESSION_KEY): session_is_temporary = False else: # If the shared session is not available, we create a temporary session. @@ -118,7 +118,7 @@ async def handle_request(self, body: bytes): await session.close() -class NextJSWebsocketProxy(NextJSProxyBase): +class NextJsWebSocketProxy(NextJsProxyBase): """ Manages WebSocket connections and proxies messages between the client (browser) and the Next.js development server. @@ -195,7 +195,7 @@ async def disconnect(self): self.nextjs_connection = None -class DjangoNextjsASGIMiddleware: +class DjangoNextJsAsgiMiddleware: """ ASGI middleware that integrates Django and Next.js applications. @@ -216,8 +216,8 @@ def __init__(self, inner_app: ASGIApp) -> None: if settings.DEBUG: # Pre-create ASGI callables for the consumers - self.nextjs_http_proxy = NextJSHttpProxy.as_asgi() - self.nextjs_websocket_proxy = NextJSWebsocketProxy.as_asgi() + 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: 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 adbecff..aad597d 100644 --- a/django_nextjs/proxy.py +++ b/django_nextjs/proxy.py @@ -7,32 +7,32 @@ from django.views import View from django_nextjs.app_settings import NEXTJS_SERVER_URL -from django_nextjs.asgi import NextJSHttpProxy, NextJSWebsocketProxy -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(NextJSHttpProxy): +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 DjangoNextjsASGIMiddleware from django_nextjs.asgi instead.", + "Use DjangoNextJsAsgiMiddleware from django_nextjs.asgi instead.", ) return super().as_asgi() -class NextJSProxyWebsocketConsumer(NextJSWebsocketProxy): +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 DjangoNextjsASGIMiddleware from django_nextjs.asgi instead.", + "Use DjangoNextJsAsgiMiddleware from django_nextjs.asgi instead.", ) return super().as_asgi() @@ -48,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 c221221..ee5afc8 100644 --- a/django_nextjs/render.py +++ b/django_nextjs/render.py @@ -12,7 +12,7 @@ from multidict import MultiMapping from .app_settings import ENSURE_CSRF_TOKEN, NEXTJS_SERVER_URL -from .asgi import DjangoNextjsASGIMiddleware +from .asgi import DjangoNextJsAsgiMiddleware from .utils import filter_mapping_obj morsel = Morsel() @@ -50,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, @@ -172,7 +172,7 @@ 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}" - if session := request.scope.get("state", {}).get(DjangoNextjsASGIMiddleware.HTTP_SESSION_KEY): + if session := request.scope.get("state", {}).get(DjangoNextJsAsgiMiddleware.HTTP_SESSION_KEY): session_is_temporary = False else: # If the shared session is not available, we create a temporary session. From ed3857fef5423afd905b8c0f0ee9c2071f3495a3 Mon Sep 17 00:00:00 2001 From: Mohammad Javad Naderi Date: Mon, 19 May 2025 16:57:19 +0330 Subject: [PATCH 6/6] Rename DjangoNextJsAsgiMiddleware to NextJsMiddleware --- README.md | 10 +++++----- django_nextjs/asgi.py | 4 ++-- django_nextjs/proxy.py | 4 ++-- django_nextjs/render.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 98867bb..1d65f75 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ In development, to simplify the setup and remove the need to a reverse proxy lik ## Setup Next.js URLs (Development Environment) -Configure your `asgi.py` with `DjangoNextJsAsgiMiddleware` as shown below: +Configure your `asgi.py` with `NextJsMiddleware` as shown below: ```python import os @@ -56,19 +56,19 @@ from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") django_asgi_app = get_asgi_application() -from django_nextjs.asgi import DjangoNextJsAsgiMiddleware +from django_nextjs.asgi import NextJsMiddleware -application = DjangoNextJsAsgiMiddleware(django_asgi_app) +application = NextJsMiddleware(django_asgi_app) ``` 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 `DjangoNextJsAsgiMiddleware` with any ASGI application. +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/): ```python -application = DjangoNextJsAsgiMiddleware( +application = NextJsMiddleware( ProtocolTypeRouter( { "http": django_asgi_app, diff --git a/django_nextjs/asgi.py b/django_nextjs/asgi.py index 6c87cb2..846aef6 100644 --- a/django_nextjs/asgi.py +++ b/django_nextjs/asgi.py @@ -91,7 +91,7 @@ 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(DjangoNextJsAsgiMiddleware.HTTP_SESSION_KEY): + 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. @@ -195,7 +195,7 @@ async def disconnect(self): self.nextjs_connection = None -class DjangoNextJsAsgiMiddleware: +class NextJsMiddleware: """ ASGI middleware that integrates Django and Next.js applications. diff --git a/django_nextjs/proxy.py b/django_nextjs/proxy.py index aad597d..c06b093 100644 --- a/django_nextjs/proxy.py +++ b/django_nextjs/proxy.py @@ -20,7 +20,7 @@ def as_asgi(cls): # https://github.com/django/daphne/issues/352 logger.warning( "NextJSProxyHttpConsumer is deprecated and will be removed in the next major release. " - "Use DjangoNextJsAsgiMiddleware from django_nextjs.asgi instead.", + "Use NextJsMiddleware from django_nextjs.asgi instead.", ) return super().as_asgi() @@ -32,7 +32,7 @@ def as_asgi(cls): # https://github.com/django/daphne/issues/352 logger.warning( "NextJSProxyWebsocketConsumer is deprecated and will be removed in the next major release. " - "Use DjangoNextJsAsgiMiddleware from django_nextjs.asgi instead.", + "Use NextJsMiddleware from django_nextjs.asgi instead.", ) return super().as_asgi() diff --git a/django_nextjs/render.py b/django_nextjs/render.py index ee5afc8..50b1385 100644 --- a/django_nextjs/render.py +++ b/django_nextjs/render.py @@ -12,7 +12,7 @@ from multidict import MultiMapping from .app_settings import ENSURE_CSRF_TOKEN, NEXTJS_SERVER_URL -from .asgi import DjangoNextJsAsgiMiddleware +from .asgi import NextJsMiddleware from .utils import filter_mapping_obj morsel = Morsel() @@ -172,7 +172,7 @@ 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}" - if session := request.scope.get("state", {}).get(DjangoNextJsAsgiMiddleware.HTTP_SESSION_KEY): + 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.