#1 WIP: long polling support

Open
perelman wants to merge 10 commits from feature/long-poll-status into history/short-polling-only

+ 1
- 0
.gitignore View File

@@ -60,3 +60,4 @@ target/
60 60
 # sqlite database
61 61
 *.sqlite3
62 62
 
63
+fear_tracker/migrations

+ 16
- 0
fear_tracker/routing.py View File

@@ -0,0 +1,16 @@
1
+from channels.http import AsgiHandler
2
+from channels.routing import ProtocolTypeRouter, URLRouter
3
+
4
+from django.conf.urls import url
5
+
6
+from . import views
7
+
8
+application = ProtocolTypeRouter({
9
+    "http": URLRouter([
10
+        url(r'^(?P<access_code>[a-zA-Z]{6})/', URLRouter([
11
+            url(r"status/(?P<hashcode>[a-z0-9]{64})/",
12
+                views.StatusLongPollConsumer, name='status'),
13
+        ])),
14
+        url(r"", AsgiHandler),
15
+    ]),
16
+})

+ 19
- 3
fear_tracker/templates/game.html View File

@@ -360,13 +360,23 @@
360 360
         el.addEventListener("change", formElementChanged);
361 361
       }
362 362
 
363
-      setInterval(function() {
363
+      function checkStatus() {
364 364
         if(activeRequests.size != 0) return;
365
+        // From https://stackoverflow.com/a/50101022
366
+
367
+        const abort = new AbortController();
368
+        const signal = abort.signal;
369
+
370
+        // 50 second timeout:
371
+        const timeoutId = setTimeout(() => abort.abort(), 50000);
372
+
365 373
         fetch(new Request("{% url 'status' access_code=access_code %}"
366 374
                            + (statusObj.hash != ""
367 375
                               ? statusObj.hash + "/"
368
-                              : "")))
376
+                              : "")),
377
+              {signal})
369 378
           .then(response => {
379
+            clearTimeout(timeoutId);
370 380
             if(response.status === 304) {
371 381
               // TODO Just skip the next step?
372 382
               return statusObj;
@@ -381,8 +391,14 @@
381 391
             } else {
382 392
               statusObj = data;
383 393
             }
394
+          })
395
+          .then(checkStatus)
396
+          .catch(() => {
397
+            // If something went wrong, wait a few seconds before retrying.
398
+            setTimeout(checkStatus, 5000);
384 399
           });
385
-      }, 5000);
400
+      }
401
+      checkStatus();
386 402
     });
387 403
   </script>
388 404
 {% endif %}

+ 1
- 3
fear_tracker/urls.py View File

@@ -27,7 +27,5 @@ urlpatterns = [
27 27
         path('update/', views.update_game, name='update_game'),
28 28
         path('qr/', views.qr_code, name='qr_code'),
29 29
         path('status/', views.status, name='status'),
30
-        url('^status/(?P<hashcode>[a-z0-9]{64})/',
31
-            views.status, name='status'),
32
-        ])),
30
+    ])),
33 31
 ]

+ 68
- 0
fear_tracker/views.py View File

@@ -5,6 +5,11 @@ import hashlib
5 5
 import json
6 6
 import qrcode
7 7
 
8
+from asgiref.sync import async_to_sync
9
+
10
+from channels.generic.http import AsyncHttpConsumer
11
+from channels.layers import get_channel_layer
12
+
8 13
 from django.db import transaction
9 14
 from django.http import HttpResponse
10 15
 from django.shortcuts import get_object_or_404, redirect, render
@@ -288,12 +293,22 @@ def handle_game_request(request, game, update):
288 293
                 res['value'] = current_value
289 294
         else:
290 295
             res = {'success': True}
296
+            async_to_sync(get_channel_layer().group_send)(
297
+                    "%s_status" % game.access_code,
298
+                    {"type": "fear_tracker.invalidate_status"})
291 299
         return HttpResponse(json.dumps(res))
292 300
 
293 301
     players = get_players_with_fear(game, current_phase, players)
294 302
     status_obj = game_status_object(game, current_phase, players)
295 303
     status_string = json.dumps(status_obj)
296 304
 
305
+    async_to_sync(get_channel_layer().group_send)(
306
+            "%s_status" % game.access_code, {
307
+                "type": "fear_tracker.hashcode_seen",
308
+                "hashcode": status_obj['hash'],
309
+                "status_string": status_string,
310
+            })
311
+
297 312
     for player in players.values():
298 313
         info = player.fear
299 314
         if not info:
@@ -324,4 +339,57 @@ def status(request, game, hashcode=None):
324 339
         return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
325 340
     else:
326 341
         status_string = json.dumps(status_obj)
342
+        async_to_sync(get_channel_layer().group_send)(
343
+                "%s_status" % game.access_code, {
344
+                    "type": "fear_tracker.hashcode_seen",
345
+                    "hashcode": status_obj['hash'],
346
+                    "status_string": status_string,
347
+                })
327 348
         return HttpResponse(status_string)
349
+
350
+
351
+class StatusLongPollConsumer(AsyncHttpConsumer):
352
+    async def handle(self, body):
353
+        self.access_code = self.scope["url_route"]["kwargs"]["access_code"]
354
+        self.hashcode = self.scope["url_route"]["kwargs"]["hashcode"]
355
+
356
+        await self.channel_layer.group_add("%s_status" % self.access_code,
357
+                                           self.channel_name)
358
+        await self.channel_layer.group_send(
359
+                "%s_status" % self.access_code, {
360
+                    "type": "fear_tracker.hashcode_seen",
361
+                    "hashcode": self.hashcode,
362
+                })
363
+
364
+    async def http_request(self, message):
365
+        """
366
+        Async entrypoint - concatenates body fragments and hands off control
367
+        to ``self.handle`` when the body has been completely received.
368
+        """
369
+        if "body" in message:
370
+            self.body.append(message["body"])
371
+        if not message.get("more_body"):
372
+            await self.handle(b"".join(self.body))
373
+
374
+    async def disconnect(self):
375
+        await self.channel_layer.group_discard("%s_status" % self.access_code,
376
+                                               self.channel_name)
377
+
378
+    async def fear_tracker_hashcode_seen(self, event):
379
+        if self.hashcode != event["hashcode"]:
380
+            if event["status_string"]:
381
+                body = event["status_string"].encode('utf-8')
382
+                await self.send_response(200, body)
383
+                await self.disconnect()
384
+            await self.channel_layer.group_send(
385
+                    "%s_status" % self.access_code, {
386
+                        "type": "fear_tracker.invalidate_status",
387
+                    })
388
+
389
+    async def fear_tracker_invalidate_status(self, event):
390
+        no_hash_status = reverse('status',
391
+                                 kwargs={'access_code': self.access_code})
392
+        await self.send_response(302, b'', headers=[
393
+            (b"Location", no_hash_status.encode('utf-8'))
394
+        ])
395
+        await self.http_disconnect(None)

+ 12
- 0
fear_tracker_site/asgi.py View File

@@ -0,0 +1,12 @@
1
+"""
2
+ASGI entrypoint. Configures Django and then runs the application
3
+defined in the ASGI_APPLICATION setting.
4
+"""
5
+
6
+import os
7
+import django
8
+from channels.routing import get_default_application
9
+
10
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "fear_tracker_site.settings")
11
+django.setup()
12
+application = get_default_application()

+ 3
- 0
fear_tracker_site/routing.py View File

@@ -0,0 +1,3 @@
1
+import fear_tracker.routing
2
+
3
+application = fear_tracker.routing.application

+ 8
- 0
fear_tracker_site/settings.py View File

@@ -31,6 +31,7 @@ ALLOWED_HOSTS = []
31 31
 # Application definition
32 32
 
33 33
 INSTALLED_APPS = [
34
+    'channels',
34 35
     'fear_tracker',
35 36
     'django.contrib.admin',
36 37
     'django.contrib.auth',
@@ -69,6 +70,7 @@ TEMPLATES = [
69 70
 ]
70 71
 
71 72
 WSGI_APPLICATION = 'fear_tracker_site.wsgi.application'
73
+ASGI_APPLICATION = 'fear_tracker_site.routing.application'
72 74
 
73 75
 
74 76
 # Database
@@ -81,6 +83,12 @@ DATABASES = {
81 83
     }
82 84
 }
83 85
 
86
+CHANNEL_LAYERS = {
87
+    "default": {
88
+        "BACKEND": "channels.layers.InMemoryChannelLayer"
89
+    }
90
+}
91
+
84 92
 
85 93
 # Password validation
86 94
 # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

Loading…
Cancel
Save