added plantUML support

fixed bug with subscription being notified multiple times
This commit is contained in:
2024-10-23 20:57:20 +08:00
parent a4b3d10c66
commit 7d9cae76c1
15 changed files with 424 additions and 163 deletions

View File

@@ -32,7 +32,8 @@ VOLUME /srv/http
WORKDIR /srv/http
ENV GRANIAN_HOST=0.0.0.0
ENV GRANIAN_INTERFACE=asginl
ENV GRANIAN_PORT=8000
ENV GRANIAN_INTERFACE=asgi
ENV GRANIAN_LOOP=asyncio
ENV GRANIAN_LOG_ENABLED=false

View File

@@ -1,9 +0,0 @@
#!/bin/bash
set -e
venv/bin/python -m build
mkdir -p docker/build
cp dist/bugis-*.whl docker/build/
cp docker/Dockerfile docker/build/Dockerfile
docker build docker/build --tag bugis:latest

View File

@@ -1,18 +0,0 @@
version: 1
disable_existing_loggers: True
handlers:
console:
class : logging.StreamHandler
formatter: default
level : INFO
stream : ext://sys.stdout
formatters:
brief:
format: '%(message)s'
default:
format: '%(asctime)s %(levelname)-8s %(name)-15s %(threadName)s %(message)s'
datefmt: '%Y-%m-%d %H:%M:%S'
loggers:
root:
handlers: [console]
level: INFO

46
docker-compose.yaml Normal file
View File

@@ -0,0 +1,46 @@
networks:
default:
external: false
ipam:
driver: default
config:
- subnet: 172.128.0.0/16
ip_range: 172.128.0.0/16
gateway: 172.128.0.254
services:
granian:
build:
context: .
user: $UID:$GID
restart: unless-stopped
# container_name: granian
environment:
PLANT_UML_SERVER_ADDRESS: http://plant_uml:8080
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
volumes:
- ${STATIC_ROOT}:/srv/http
plant_uml:
image: plantuml/plantuml-server:jetty
# container_name: plantUML
restart: unless-stopped
tmpfs: /tmp/jetty
deploy:
resources:
limits:
cpus: "4"
memory: 1G
nginx:
image: gitea.woggioni.net/woggioni/nginx:v1.27.2
# container_name: nginx
restart: unless-stopped
depends_on:
- granian
volumes:
- ./conf/nginx-bugis.conf:/etc/nginx/conf.d/bugis.conf:ro
ports:
- 127.0.0.1:80:8080

View File

@@ -28,7 +28,8 @@ dependencies = [
"pwo",
"PyYAML",
"pygraphviz",
"aiofiles"
"aiofiles",
"aiohttp[speedups]"
]
[project.optional-dependencies]

View File

@@ -1,22 +1,39 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml
# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=dev --output-file=requirements-dev.txt --strip-extras pyproject.toml
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
--extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
aiodns==3.2.0
# via aiohttp
aiofiles==24.1.0
# via bugis (pyproject.toml)
aiohappyeyeballs==2.4.3
# via aiohttp
aiohttp==3.10.10
# via bugis (pyproject.toml)
aiosignal==1.3.1
# via aiohttp
asttokens==2.4.1
# via stack-data
async-timeout==4.0.3
# via aiohttp
attrs==24.2.0
# via aiohttp
backports-tarfile==1.2.0
# via jaraco-context
brotli==1.1.0
# via aiohttp
build==1.2.2.post1
# via bugis (pyproject.toml)
certifi==2024.8.30
# via requests
cffi==1.17.1
# via cryptography
# via
# cryptography
# pycares
charset-normalizer==3.4.0
# via requests
click==8.1.7
@@ -29,14 +46,24 @@ decorator==5.1.1
# ipython
docutils==0.21.2
# via readme-renderer
exceptiongroup==1.2.2
# via ipython
executing==2.1.0
# via stack-data
frozenlist==1.4.1
# via
# aiohttp
# aiosignal
granian==1.6.1
# via bugis (pyproject.toml)
idna==3.10
# via requests
# via
# requests
# yarl
importlib-metadata==8.5.0
# via twine
# via
# keyring
# twine
ipdb==0.13.13
# via bugis (pyproject.toml)
ipython==8.28.0
@@ -67,6 +94,10 @@ more-itertools==10.5.0
# via
# jaraco-classes
# jaraco-functools
multidict==6.1.0
# via
# aiohttp
# yarl
mypy==1.12.1
# via bugis (pyproject.toml)
mypy-extensions==1.0.0
@@ -83,12 +114,16 @@ pkginfo==1.10.0
# via twine
prompt-toolkit==3.0.48
# via ipython
propcache==0.2.0
# via yarl
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.3
# via stack-data
pwo==0.0.3
# via bugis (pyproject.toml)
pycares==4.4.0
# via aiodns
pycparser==2.22
# via cffi
pygments==2.18.0
@@ -121,6 +156,11 @@ six==1.16.0
# via asttokens
stack-data==0.6.3
# via ipython
tomli==2.0.2
# via
# build
# ipdb
# mypy
traitlets==5.14.3
# via
# ipython
@@ -129,8 +169,11 @@ twine==5.1.1
# via bugis (pyproject.toml)
typing-extensions==4.7.1
# via
# ipython
# multidict
# mypy
# pwo
# rich
urllib3==2.2.3
# via
# requests
@@ -141,5 +184,7 @@ watchdog==5.0.3
# via bugis (pyproject.toml)
wcwidth==0.2.13
# via prompt-toolkit
yarl==1.16.0
# via aiohttp
zipp==3.20.2
# via importlib-metadata

View File

@@ -1,22 +1,53 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --extra=run --output-file=requirements-run.txt pyproject.toml
# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=run --output-file=requirements-run.txt --strip-extras pyproject.toml
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
--extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
aiodns==3.2.0
# via aiohttp
aiofiles==24.1.0
# via bugis (pyproject.toml)
aiohappyeyeballs==2.4.3
# via aiohttp
aiohttp==3.10.10
# via bugis (pyproject.toml)
aiosignal==1.3.1
# via aiohttp
async-timeout==4.0.3
# via aiohttp
attrs==24.2.0
# via aiohttp
brotli==1.1.0
# via aiohttp
cffi==1.17.1
# via pycares
click==8.1.7
# via granian
frozenlist==1.4.1
# via
# aiohttp
# aiosignal
granian==1.6.1
# via bugis (pyproject.toml)
idna==3.10
# via yarl
markdown==3.7
# via bugis (pyproject.toml)
multidict==6.1.0
# via
# aiohttp
# yarl
propcache==0.2.0
# via yarl
pwo==0.0.3
# via bugis (pyproject.toml)
pycares==4.4.0
# via aiodns
pycparser==2.22
# via cffi
pygments==2.18.0
# via bugis (pyproject.toml)
pygraphviz==1.14
@@ -24,8 +55,12 @@ pygraphviz==1.14
pyyaml==6.0.2
# via bugis (pyproject.toml)
typing-extensions==4.7.1
# via pwo
# via
# multidict
# pwo
uvloop==0.21.0
# via granian
watchdog==5.0.3
# via bugis (pyproject.toml)
yarl==1.16.0
# via aiohttp

View File

@@ -1,18 +1,49 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --extra='' --output-file=requirements-.txt pyproject.toml
# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=requirements.txt --strip-extras pyproject.toml
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
--extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
aiodns==3.2.0
# via aiohttp
aiofiles==24.1.0
# via bugis (pyproject.toml)
aiohappyeyeballs==2.4.3
# via aiohttp
aiohttp==3.10.10
# via bugis (pyproject.toml)
aiosignal==1.3.1
# via aiohttp
async-timeout==4.0.3
# via aiohttp
attrs==24.2.0
# via aiohttp
brotli==1.1.0
# via aiohttp
cffi==1.17.1
# via pycares
frozenlist==1.4.1
# via
# aiohttp
# aiosignal
idna==3.10
# via yarl
markdown==3.7
# via bugis (pyproject.toml)
multidict==6.1.0
# via
# aiohttp
# yarl
propcache==0.2.0
# via yarl
pwo==0.0.3
# via bugis (pyproject.toml)
pycares==4.4.0
# via aiodns
pycparser==2.22
# via cffi
pygments==2.18.0
# via bugis (pyproject.toml)
pygraphviz==1.14
@@ -20,6 +51,10 @@ pygraphviz==1.14
pyyaml==6.0.2
# via bugis (pyproject.toml)
typing-extensions==4.7.1
# via pwo
# via
# multidict
# pwo
watchdog==5.0.3
# via bugis (pyproject.toml)
yarl==1.16.0
# via aiohttp

View File

@@ -1,11 +1,11 @@
import logging
from logging.config import dictConfig as configure_logging
from os import environ
from yaml import safe_load
from pathlib import Path
logging_configuration_file = environ.get("LOGGING_CONFIGURATION_FILE", Path(__file__).parent / 'default-conf' / 'logging.yaml')
with open(logging_configuration_file, 'r') as input_file:
from yaml import safe_load
from .configuration import Configuration
with open(Configuration.instance.logging_configuration_file, 'r') as input_file:
conf = safe_load(input_file)
configure_logging(conf)
@@ -13,55 +13,51 @@ with open(logging_configuration_file, 'r') as input_file:
from pwo import Maybe
from .server import Server
from asyncio import get_running_loop
from .asgi_utils import decode_headers
from typing import Optional
log = logging.getLogger('access')
log.propagate = False
_server = None
_server : Optional[Server] = None
def decode_headers(headers):
result = dict()
for key, value in headers:
if isinstance(key, bytes):
key = key.decode()
if isinstance(value, bytes):
value = value.decode()
l = result.setdefault(key, list())
l.append(value)
return {
k: tuple(v) for k, v in result.items()
}
async def application(ctx, receive, send):
async def application(scope, receive, send):
global _server
if _server is None:
_server = Server(loop=get_running_loop(), prefix=None)
def maybe_log(evt):
d = {
'response_headers': (Maybe.of_nullable(evt.get('headers'))
.map(decode_headers)
.or_none()),
'status': evt['status']
}
log.info(None, extra=dict(**{k : v for k, v in d.items() if k is not None}, **ctx))
def wrapped_send(*args, **kwargs):
result = send(*args, **kwargs)
(Maybe.of(args)
.filter(lambda it: len(it) > 0)
.map(lambda it: it[0])
.filter(lambda it: it.get('type') == 'http.response.start')
.if_present(maybe_log))
return result
await _server.handle_request(
ctx['method'],
ctx['path'],
Maybe.of([header[1] for header in ctx['headers'] if header[0].decode().lower() == 'if-none-match'])
.filter(lambda it: len(it) > 0)
.map(lambda it: it[0])
.map(lambda it: it.decode())
.or_else(None),
Maybe.of_nullable(ctx.get('query_string', None)).map(lambda it: it.decode()).or_else(None),
wrapped_send
)
if scope['type'] == 'lifespan':
while True:
message = await receive()
if message['type'] == 'lifespan.startup':
_server = Server(loop=get_running_loop(), prefix=None)
await send({'type': 'lifespan.startup.complete'})
elif message['type'] == 'lifespan.shutdown':
await _server.stop()
await send({'type': 'lifespan.shutdown.complete'})
else:
def maybe_log(evt):
d = {
'response_headers': (Maybe.of_nullable(evt.get('headers'))
.map(decode_headers)
.or_none()),
'status': evt['status']
}
log.info(None, extra=dict(**{k : v for k, v in d.items() if k is not None}, **scope))
def wrapped_send(*args, **kwargs):
result = send(*args, **kwargs)
(Maybe.of(args)
.filter(lambda it: len(it) > 0)
.map(lambda it: it[0])
.filter(lambda it: it.get('type') == 'http.response.start')
.if_present(maybe_log))
return result
await _server.handle_request(
scope['method'],
scope['path'],
Maybe.of([header[1] for header in scope['headers'] if header[0].decode().lower() == 'if-none-match'])
.filter(lambda it: len(it) > 0)
.map(lambda it: it[0])
.map(lambda it: it.decode())
.or_else(None),
Maybe.of_nullable(scope.get('query_string', None)).map(lambda it: it.decode()).or_else(None),
wrapped_send
)

26
src/bugis/asgi_utils.py Normal file
View File

@@ -0,0 +1,26 @@
from typing import Tuple, Dict, Sequence
type StrOrStrings = (str, Sequence[str])
def decode_headers(headers: Sequence[Tuple[bytes, bytes]]) -> Dict[str, Sequence[str]]:
result = dict()
for key, value in headers:
if isinstance(key, bytes):
key = key.decode()
if isinstance(value, bytes):
value = value.decode()
l = result.setdefault(key, list())
l.append(value)
return {
k: tuple(v) for k, v in result.items()
}
def encode_headers(headers: Dict[str, StrOrStrings]) -> Tuple[Tuple[bytes, bytes], ...]:
result = []
for key, value in headers.items():
if isinstance(value, str):
result.append((key.encode(), value.encode()))
elif isinstance(value, Sequence):
for single_value in value:
result.append((key.encode(), single_value.encode()))
return tuple(result)

View File

@@ -4,33 +4,38 @@ from watchdog.events import FileSystemEventHandler, FileSystemEvent, PatternMatc
from watchdog.observers import Observer
from watchdog.events import FileMovedEvent, FileClosedEvent, FileCreatedEvent, FileModifiedEvent
from pathlib import Path
from asyncio import Queue, AbstractEventLoop, Future, CancelledError, Task
from typing import Callable
from asyncio import Queue, AbstractEventLoop, Future, CancelledError, Task, gather
from typing import Callable, Optional
from logging import getLogger, Logger
log: Logger = getLogger(__name__)
class Subscription:
_unsubscribe_callback: Callable[['Subscription'], None]
_event: Future
_event: Optional[Future]
_loop: AbstractEventLoop
def __init__(self, unsubscribe: Callable[['Subscription'], None], loop: AbstractEventLoop):
self._unsubscribe_callback = unsubscribe
self._event: Future = loop.create_future()
self._event: Optional[Future] = None
self._loop = loop
def unsubscribe(self) -> None:
self._event.cancel()
self._unsubscribe_callback(self)
log.debug('Deleted subscription %s', id(self))
async def wait(self, tout: float) -> bool:
handle = self._loop.call_later(tout, lambda: self._event.cancel())
self._event = self._loop.create_future()
def callback():
if not self._event.done():
self._event.set_result(False)
handle = self._loop.call_later(tout, callback)
try:
log.debug('Subscription %s is waiting for an event', id(self))
await self._event
return True
return await self._event
except CancelledError:
return False
finally:
@@ -38,7 +43,8 @@ class Subscription:
def notify(self) -> None:
log.debug('Subscription %s notified', id(self))
self._event.set_result(None)
if not self._event.done():
self._event.set_result(True)
def reset(self) -> None:
self._event = self._loop.create_future()
@@ -106,6 +112,7 @@ class SubscriptionManager:
def unsubscribe_callback(subscription):
subscriptions_per_path.remove(subscription)
log.debug('Removed subscription %s to path %s', id(result), path)
result = Subscription(unsubscribe_callback, self._loop)
log.debug('Created subscription %s to path %s', id(result), path)
@@ -116,21 +123,29 @@ class SubscriptionManager:
subscriptions = self._subscriptions
subscriptions_per_path = subscriptions.get(path, None)
if subscriptions_per_path:
log.debug(f"Subscriptions on '{path}': {len(subscriptions_per_path)}")
for s in subscriptions_per_path:
s.notify()
async def process_events(self):
async for evt in AsyncQueueIterator(self._queue):
log.debug(f"Processed event for path '{evt}'")
self._notify_subscriptions(evt)
log.debug(f"Event processor has completed")
def post_event(self, path):
self._loop.call_soon_threadsafe(self._queue.put_nowait, path)
def callback():
self._queue.put_nowait(path)
log.debug(f"Posted event for path '{path}', queue size: {self._queue.qsize()}")
self._loop.call_soon_threadsafe(callback)
class FileWatcher(PatternMatchingEventHandler):
_subscription_manager: SubscriptionManager
_loop: AbstractEventLoop
_subscription_manager_loop: Task
_running_tasks : Future
def __init__(self, path):
super().__init__(patterns=['*.md'],
@@ -141,8 +156,10 @@ class FileWatcher(PatternMatchingEventHandler):
self._observer.schedule(self, path=path, recursive=True)
self._loop = asyncio.get_running_loop()
self._subscription_manager = SubscriptionManager(self._loop)
self._loop.run_in_executor(None, self._observer.start)
self._subscription_manager_loop = self._loop.create_task(self._subscription_manager.process_events())
self._running_tasks = gather(
self._loop.run_in_executor(None, self._observer.start),
self._loop.create_task(self._subscription_manager.process_events())
)
async def stop(self) -> None:
def _observer_stop():
@@ -150,8 +167,8 @@ class FileWatcher(PatternMatchingEventHandler):
self._observer.join()
self._subscription_manager.post_event(None)
self._loop.run_in_executor(None, _observer_stop)
await self._subscription_manager_loop
await self._loop.run_in_executor(None, _observer_stop)
await self._running_tasks
def subscribe(self, path: str) -> Subscription:
return self._subscription_manager.subscribe(path)

View File

@@ -0,0 +1,45 @@
import os
from os import environ
from pathlib import Path
from dataclasses import dataclass
class ClassPropertyDescriptor(object):
def __init__(self, fget, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, obj, klass=None):
if klass is None:
klass = type(obj)
return self.fget.__get__(obj, klass)()
def __set__(self, obj, value):
if not self.fset:
raise AttributeError("can't set attribute")
type_ = type(obj)
return self.fset.__get__(obj, type_)(value)
def setter(self, func):
if not isinstance(func, (classmethod, staticmethod)):
func = classmethod(func)
self.fset = func
return self
def classproperty(func):
if not isinstance(func, (classmethod, staticmethod)):
func = classmethod(func)
return ClassPropertyDescriptor(func)
@dataclass(frozen=True)
class Configuration:
logging_configuration_file : str = environ.get("LOGGING_CONFIGURATION_FILE", Path(__file__).parent / 'default-conf' / 'logging.yaml')
plant_uml_server_address : str = environ.get('PLANT_UML_SERVER_ADDRESS', None)
@classproperty
def instance(cls) -> 'Configuration':
return Configuration()

17
src/bugis/plantuml.py Normal file
View File

@@ -0,0 +1,17 @@
from typing import TYPE_CHECKING
from aiofiles import open as async_open
from aiohttp import ClientSession
from .configuration import Configuration
from yarl import URL
if TYPE_CHECKING:
from _typeshed import StrOrBytesPath
async def render_plant_uml(path: 'StrOrBytesPath') -> bytes:
async with ClientSession() as session:
url = URL(Configuration.instance.plant_uml_server_address) / 'svg'
async with async_open(path, 'rb') as file:
source = await file.read()
async with session.post(url, data=source) as response:
response.raise_for_status()
return await response.read()

View File

@@ -1,25 +1,26 @@
import logging
from os import getcwd
from mimetypes import init as mimeinit, guess_type
import hashlib
from os.path import join, normpath, splitext, relpath, basename
import logging
from asyncio import AbstractEventLoop
from asyncio import Future
from aiofiles.os import listdir
from aiofiles.ospath import exists, isdir, isfile, getmtime
from io import BytesIO
from mimetypes import init as mimeinit, guess_type
from os import getcwd
from os.path import join, normpath, splitext, relpath, basename
from typing import Callable, TYPE_CHECKING, Optional, Awaitable, AsyncGenerator, Any
import pygraphviz as pgv
from aiofiles import open as async_open
from aiofiles.base import AiofilesContextManager
from aiofiles.os import listdir
from aiofiles.ospath import exists, isdir, isfile, getmtime
from aiofiles.threadpool.binary import AsyncBufferedReader
from asyncio import AbstractEventLoop
from .md2html import compile_html, load_from_cache, STATIC_RESOURCES, MARDOWN_EXTENSIONS
from shutil import which
import pygraphviz as pgv
from io import BytesIO
from typing import Callable, TYPE_CHECKING, Optional, Awaitable, AsyncGenerator, Any
from .async_watchdog import FileWatcher
from pwo import Maybe
from .asgi_utils import encode_headers
from .async_watchdog import FileWatcher
from .md2html import compile_html, load_from_cache, STATIC_RESOURCES, MARDOWN_EXTENSIONS
from .plantuml import render_plant_uml
if TYPE_CHECKING:
from _typeshed import StrOrBytesPath
@@ -45,6 +46,9 @@ def is_markdown(filepath):
def is_dotfile(filepath):
return has_extension(filepath, ".dot")
def is_plant_uml(filepath):
return has_extension(filepath, ".puml")
logger = logging.getLogger(__name__)
class Server:
@@ -91,11 +95,11 @@ class Server:
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
('content-type', f'{mime_type}; charset=UTF-8'),
('etag', f'W/"{digest}"'.encode()),
('Cache-Control', 'must-revalidate, max-age=86400'),
]
'headers': encode_headers({
'content-type': f'{mime_type}; charset=UTF-8',
'etag': f'W/{digest}',
'Cache-Control': 'must-revalidate, max-age=86400',
})
})
await send({
'type': 'http.response.body',
@@ -124,7 +128,7 @@ class Server:
lambda: getmtime(path)
)
if etag != digest:
if exists(path) and await isfile(path):
if await exists(path) and await isfile(path):
await self.render_markdown(url_path, path, True, digest, send)
return
else:
@@ -136,7 +140,7 @@ class Server:
elif is_markdown(path):
raw = query_string == 'reload'
await self.render_markdown(url_path, path, raw, digest, send)
elif is_dotfile(path) and which("dot"):
elif is_dotfile(path):
def render_graphviz(filepath: StrOrBytesPath) -> bytes:
logger.debug("Starting Graphviz rendering for file '%s'", filepath)
graph = pgv.AGraph(filepath)
@@ -147,11 +151,28 @@ class Server:
await send({
'type': 'http.response.start',
'status': 200,
'headers': (
('Content-Type', 'image/svg+xml; charset=UTF-8'),
('Etag', f'W/"{digest}"'),
('Cache-Control', 'no-cache'),
)
'headers': encode_headers({
'Content-Type': 'image/svg+xml; charset=UTF-8',
'Etag': f'W/{digest}',
'Cache-Control': 'no-cache',
})
})
await send({
'type': 'http.response.body',
'body': body
})
elif is_plant_uml(path):
logger.debug("Starting PlantUML rendering for file '%s'", path)
body = await render_plant_uml(path)
logger.debug("Completed PlantUML rendering for file '%s'", path)
await send({
'type': 'http.response.start',
'status': 200,
'headers': encode_headers({
'Content-Type': 'image/svg+xml; charset=UTF-8',
'Etag': f'W/{digest}',
'Cache-Control': 'no-cache'
})
})
await send({
'type': 'http.response.body',
@@ -170,11 +191,11 @@ class Server:
await send({
'type': 'http.response.start',
'status': 200,
'headers': (
('Content-Type', guess_type(basename(path))[0] or 'application/octet-stream'),
('Etag', f'W/"{digest}"'),
('Cache-Control', 'no-cache')
)
'headers': encode_headers({
'Content-Type': guess_type(basename(path))[0] or 'application/octet-stream',
'Etag': f'W/{digest}',
'Cache-Control': 'no-cache'
})
})
async for chunk in read_file(path):
@@ -194,9 +215,9 @@ class Server:
await send({
'type': 'http.response.start',
'status': 200,
'headers': (
('Content-Type', 'text/html; charset=UTF-8'),
)
'headers': encode_headers({
'Content-Type': 'text/html; charset=UTF-8',
})
})
await send({
'type': 'http.response.body',
@@ -288,11 +309,11 @@ class Server:
await send({
'type': 'http.response.start',
'status': 200,
'headers': (
('Content-Type', 'text/html; charset=UTF-8'),
('Etag', f'W/{digest}'),
('Cache-Control', 'no-cache'),
)
'headers': encode_headers({
'Content-Type': 'text/html; charset=UTF-8',
'Etag': f'W/{digest}',
'Cache-Control': 'no-cache',
})
})
await send({
'type': 'http.response.body',
@@ -300,14 +321,14 @@ class Server:
})
@staticmethod
async def not_modified(send, digest: str, cache_control=('Cache-Control', 'no-cache')) -> []:
async def not_modified(send, digest: str, cache_control: str ='no-cache') -> []:
await send({
'type': 'http.response.start',
'status': 304,
'headers': (
(b'Etag', f'W/{digest}'.encode()),
cache_control
)
'headers': encode_headers({
'Etag': f'W/{digest}',
'Cache-Control': cache_control
})
})
await send({
'type': 'http.response.body',
@@ -350,3 +371,6 @@ class Server:
async for entry in await ls(file_filter):
result += '<li><a href="' + entry + '"/>' + entry + '</li>'
return result
async def stop(self):
await self.file_watcher.stop()

View File

@@ -1,21 +1,21 @@
function req(first) {
function req(first, previousETag) {
const minInterval = 2000;
const start = new Date().getTime();
const xmlhttp = new XMLHttpRequest();
xmlhttp.onload = function() {
if (xmlhttp.status == 200) {
const eTag = xmlhttp.getResponseHeader("Etag")
if (xmlhttp.status == 200 && eTag !== previousETag) {
document.querySelector("article.markdown-body").innerHTML = xmlhttp.responseText;
} else if(xmlhttp.status == 304) {
} else {
console.log(xmlhttp.status, xmlhttp.statusText);
}
const nextCall = Math.min(1000, Math.max(0, 1000 - (new Date().getTime() - start)));
setTimeout(req, nextCall, false);
const nextCall = Math.min(minInterval, Math.max(0, minInterval - (new Date().getTime() - start)));
setTimeout(req, nextCall, false, eTag);
};
xmlhttp.onerror = function() {
console.log(xmlhttp.status, xmlhttp.statusText);
setTimeout(req, 1000, false);
setTimeout(req, minInterval, false, previousETag);
};
xmlhttp.open("GET", location.pathname + "?reload", true);
xmlhttp.send();
}
req(true);
req(true, null);