Skip to content

Commit 05b76d1

Browse files
authored
Merge pull request #5 from G4brym/add-d1-binding
Add support for workers and D1 binding
2 parents 103941a + 6ceebe4 commit 05b76d1

22 files changed

+672
-525
lines changed

README.md

Lines changed: 154 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,191 @@
11
# django-cf
2-
Django database engine for Cloudflare D1 and Durable Objects
2+
django-cf is a package that integrates Django with Cloudflare products
3+
4+
Integrations:
5+
- Cloudflare D1
6+
- Cloudflare Workers
37

48
## Installation
59

610
```bash
711
pip install django-cf
812
```
913

10-
## Using with D1
14+
## Cloudflare D1
1115

12-
The D1 engine uses the HTTP api directly from Cloudflare, meaning you only need to create a new D1 database, then
13-
create an API token with `D1 read` permission, and you are good to go!
16+
Cloudflare D1 doesn't support transactions, meaning all execute queries are final and rollbacks are not available.
17+
18+
A simple tutorial is [available here](https://massadas.com/posts/django-meets-cloudflare-d1/) for you to read.
1419

15-
But using an HTTP endpoint for executing queries one by one is very slow, and currently there is no way to accelerate
16-
it, until a websocket endpoint is available.
1720

18-
The HTTP api doesn't support transactions, meaning all execute queries are final and rollbacks are not available.
21+
### D1 Binding
22+
23+
You can now deploy Django into a Cloudflare Python Worker, and in that environment, D1 is available as a Binding for
24+
faster queries.
1925

2026
```python
2127
DATABASES = {
2228
'default': {
23-
'ENGINE': 'django_d1',
29+
'ENGINE': 'django_cf.d1_binding',
30+
'CLOUDFLARE_BINDING': 'DB',
31+
}
32+
}
33+
```
34+
35+
### D1 API
36+
37+
The D1 engine uses the HTTP api directly from Cloudflare, meaning you only need to create a new D1 database, then
38+
create an API token with `D1 read` and `D1 write` permission, and you are good to go!
39+
40+
But using an HTTP endpoint for executing queries one by one is very slow, and currently there is no way to speed up
41+
it.
42+
43+
```python
44+
DATABASES = {
45+
'default': {
46+
'ENGINE': 'django_cf.d1_api',
2447
'CLOUDFLARE_DATABASE_ID': '<database_id>',
2548
'CLOUDFLARE_ACCOUNT_ID': '<account_id>',
2649
'CLOUDFLARE_TOKEN': '<token>',
2750
}
2851
}
2952
```
3053

31-
The Cloudflare token requires D1 Edit permissions.
54+
## Cloudflare Workers
3255

33-
A simple tutorial is [available here](https://massadas.com/posts/django-meets-cloudflare-d1/) for you to read.
56+
django-cf includes an adapter that allows you to run Django inside Cloudflare Workers named `DjangoCFAdapter`
57+
58+
Suggested project structure
59+
```
60+
root
61+
|-> src/
62+
|-> src/manage.py
63+
|-> src/worker.py <-- Wrangler entrypoint
64+
|-> src/your-apps-here/
65+
|-> src/vendor/... <-- Project dependencies, details bellow
66+
|-> vendor.txt
67+
|-> wrangler.jsonc
68+
```
69+
70+
`vendor.txt`
71+
```txt
72+
django==5.1.2
73+
django-cf
74+
tzdata
75+
```
76+
77+
`wrangler.jsonc`
78+
```jsonc
79+
{
80+
"name": "django-on-workers",
81+
"main": "src/worker.py",
82+
"compatibility_flags": [
83+
"python_workers_20250116",
84+
"python_workers"
85+
],
86+
"compatibility_date": "2025-04-10",
87+
"assets": {
88+
"directory": "./staticfiles/"
89+
},
90+
"rules": [
91+
{
92+
"globs": [
93+
"vendor/**/*.py",
94+
"vendor/**/*.mo",
95+
"vendor/tzdata/**/",
96+
],
97+
"type": "Data",
98+
"fallthrough": true
99+
}
100+
],
101+
"d1_databases": [
102+
{
103+
"binding": "DB",
104+
"database_name": "my-django-db",
105+
"database_id": "924e612f-6293-4a3f-be66-cce441957b03",
106+
}
107+
],
108+
"observability": {
109+
"enabled": true
110+
}
111+
}
112+
```
34113

35-
## Using with Durable Objects
114+
`src/worker.py`
115+
```python
116+
from django_cf import DjangoCFAdapter
36117

37-
The DO engine, requires you to deploy [workers-dbms](https://github.com/G4brym/workers-dbms) on your cloudflare account.
38-
This engine uses a websocket endpoint to keep the connection alive, meaning sql queries are executed way faster.
118+
async def on_fetch(request, env):
119+
from app.wsgi import application # Update acording to your project structure
120+
adapter = DjangoCFAdapter(application)
39121

40-
workers-dbms have an experimental transaction support, everything should be working out of the box, but you should keep
41-
an eye out for weird issues and report them back.
122+
return adapter.handle_request(request)
42123

124+
```
43125

126+
Then run this command to vendor your dependencies:
127+
```bash
128+
pip install -t src/vendor -r vendor.txt
129+
```
130+
131+
To bundle static assets with your worker, add this line to your `settings.py`, this will place the assets outside the src folder
44132
```python
45-
DATABASES = {
46-
'default': {
47-
'ENGINE': 'django_dbms',
48-
'WORKERS_DBMS_ENDPOINT': '<websocket_endpoint>', # This should start with wss://
49-
'WORKERS_DBMS_ACCESS_ID': '<access_id>', # Optional, but highly recommended!
50-
'WORKERS_DBMS_ACCESS_SECRET': '<access_secret>', # Optional, but highly recommended!
51-
}
52-
}
133+
STATIC_URL = 'static/'
134+
STATIC_ROOT = BASE_DIR.parent.joinpath('staticfiles').joinpath('static')
53135
```
54136

137+
And this command generate the static assets:
138+
```bash
139+
python src/manage.py collectstatic
140+
```
141+
142+
Now deploy your worker
143+
```bash
144+
npx wrangler deploy
145+
```
146+
147+
### Running migrations and other commands
148+
In the ideal setup, your application will have two settings, one for production and another for development.
149+
150+
- The production one, will connect to D1 via using the binding, as this is way faster.
151+
- The development one, will connect using the D1 API.
152+
153+
Using this setup, you can apply the migrations from your local machine.
154+
155+
In case that is not enought for you, here is a snippet that allows you to apply D1 migrations using a deployed worker:
156+
157+
Just add these new routes to your `urls.py`:
158+
159+
```python
160+
from django.contrib import admin
161+
from django.contrib.auth import get_user_model;
162+
from django.http import JsonResponse
163+
from django.urls import path
164+
165+
def create_admin(request):
166+
User = get_user_model();
167+
User.objects.create_superuser('admin', '[email protected]', 'password')
168+
return JsonResponse({"user": "ok"})
169+
170+
def migrate(request):
171+
from django.core.management import execute_from_command_line
172+
execute_from_command_line(["manage.py", "migrate"])
173+
174+
return JsonResponse({"migrations": "ok"})
175+
176+
urlpatterns = [
177+
path('create-admin', create_admin),
178+
path('migrate', migrate),
179+
path('admin/', admin.site.urls),
180+
]
181+
```
182+
183+
You may now call your worker to apply all missing migrations, ex: `https://django-on-workers.{username}.workers.dev/migrate`
55184

56185
## Limitations
57186

58187
When using D1 engine, queries are expected to be slow, and transactions are disabled.
59188

189+
A lot of query features are additionally disabled, for example inline sql functions, used extensively inside Django Admin
190+
60191
Read all Django limitations for SQLite [databases here](https://docs.djangoproject.com/en/5.0/ref/databases/#sqlite-notes).

django_cf/__init__.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import os
2+
from io import BytesIO
3+
4+
class DjangoCFAdapter:
5+
def __init__(self, app):
6+
self.app = app
7+
8+
async def handle_request(self, request):
9+
os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'false')
10+
from js import Object, Response, URL, console
11+
12+
headers = []
13+
for header in request.headers:
14+
headers.append(tuple([header[0], header[1]]))
15+
16+
url = URL.new(request.url)
17+
assert url.protocol[-1] == ":"
18+
scheme = url.protocol[:-1]
19+
path = url.pathname
20+
assert "?".startswith(url.search[0:1])
21+
query_string = url.search[1:]
22+
method = str(request.method).upper()
23+
24+
host = url.host.split(':')[0]
25+
26+
wsgi_request = {
27+
'REQUEST_METHOD': method,
28+
'PATH_INFO': path,
29+
'QUERY_STRING': query_string,
30+
'SERVER_NAME': host,
31+
'SERVER_PORT': url.port,
32+
'SERVER_PROTOCOL': 'HTTP/1.1',
33+
'wsgi.input': BytesIO(b''),
34+
'wsgi.errors': console.error,
35+
'wsgi.version': (1, 0),
36+
'wsgi.multithread': False,
37+
'wsgi.multiprocess': False,
38+
'wsgi.run_once': True,
39+
'wsgi.url_scheme': scheme,
40+
}
41+
42+
if request.headers.get('content-type'):
43+
wsgi_request['CONTENT_TYPE'] = request.headers.get('content-type')
44+
45+
if request.headers.get('content-length'):
46+
wsgi_request['CONTENT_LENGTH'] = request.headers.get('content-length')
47+
48+
for header in request.headers:
49+
wsgi_request[f'HTTP_{header[0].upper()}'] = header[1]
50+
51+
if method in ['POST', 'PUT', 'PATCH']:
52+
body = (await request.arrayBuffer()).to_bytes()
53+
wsgi_request['wsgi.input'] = BytesIO(body)
54+
55+
def start_response(status_str, response_headers):
56+
nonlocal status, headers
57+
status = status_str
58+
headers = response_headers
59+
60+
resp = self.app(wsgi_request, start_response)
61+
status = resp.status_code
62+
headers = resp.headers
63+
64+
final_response = Response.new(
65+
resp.content.decode('utf-8'), headers=Object.fromEntries(headers.items()), status=status
66+
)
67+
68+
for k, v in resp.cookies.items():
69+
value = str(v)
70+
final_response.headers.set('Set-Cookie', value.replace('Set-Cookie: ', '', 1));
71+
72+
return final_response
File renamed without changes.

django_d1/base.py renamed to django_cf/d1_api/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class DatabaseWrapper(SQLiteDatabaseWrapper):
2626
transaction_modes = frozenset([])
2727

2828
def get_database_version(self):
29-
return (4,)
29+
return (4, )
3030

3131
def get_connection_params(self):
3232
settings_dict = self.settings_dict
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)