clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / python / blockdiag-family / src / entry.py
entry.py 7.8K
1import hashlib2import json3from urllib.parse import quote, urlparse45from workers import Request, Response, WorkerEntrypoint67from protocol import MAX_REQUEST_BYTES, ProtocolError, parse_render_input8from render import MODULES, render_svg9from svg import sanitize_and_decorate101112UNIT_ID = "blockdiag-family"13CACHE_SCHEMA = "1"14EXPOSED_HEADERS = ", ".join([15 "Cache-Control",16 "Content-Type",17 "X-Diagram-Cache",18 "X-Diagram-Engine",19 "X-Diagram-Engine-Version",20 "X-Diagram-Pipeline",21 "X-Diagram-Renderer",22 "X-Diagram-Unit",23 "X-Renderer-Build",24])252627def _engine(request: Request) -> str:28 hostname = urlparse(request.url).hostname or ""29 return hostname.split(".", 1)[0].lower()303132def _json_response(value, status=200, cache_control="no-store") -> Response:33 return Response(34 json.dumps(value, separators=(",", ":")),35 status=status,36 headers={37 "Access-Control-Allow-Origin": "*",38 "Cache-Control": cache_control,39 "Content-Type": "application/json; charset=utf-8",40 },41 )424344def _error(error: ProtocolError) -> Response:45 return _json_response({"error": {"code": error.code, "message": str(error)}}, status=error.status)464748async def _bounded_body(request: Request) -> str:49 declared_length = request.headers.get("Content-Length")50 if declared_length is not None:51 try:52 parsed_length = int(declared_length)53 except ValueError as error:54 raise ProtocolError(400, "invalid_request", "Content-Length is invalid.") from error55 if parsed_length < 0:56 raise ProtocolError(400, "invalid_request", "Content-Length is invalid.")57 if parsed_length > MAX_REQUEST_BYTES:58 raise ProtocolError(413, "request_too_large", "Render request is too large.")5960 body = request.body61 if body is None:62 raise ProtocolError(400, "invalid_request", "A JSON request body is required.")63 reader = body.getReader()64 chunks: list[bytes] = []65 length = 066 while True:67 result = await reader.read()68 if result.done:69 break70 chunk = result.value.to_bytes()71 length += len(chunk)72 if length > MAX_REQUEST_BYTES:73 await reader.cancel("request too large")74 raise ProtocolError(413, "request_too_large", "Render request is too large.")75 chunks.append(chunk)76 try:77 return b"".join(chunks).decode("utf-8")78 except UnicodeDecodeError as error:79 raise ProtocolError(400, "invalid_json", "Render request is not valid UTF-8 JSON.") from error808182def _cache_url(engine: str, build: str, render_input) -> str:83 canonical = json.dumps({84 "engine": engine,85 "source": render_input.source,86 "options": render_input.options,87 "metadata": {"title": render_input.title, "description": render_input.description},88 "presentation": {89 "background": render_input.background,90 "padding": render_input.padding,91 "frame": render_input.frame,92 },93 }, sort_keys=True, separators=(",", ":"))94 digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()95 return f"https://diagramzip-blockdiag-cache.invalid/{CACHE_SCHEMA}/{quote(engine)}/{quote(build)}/{digest}"969798def _with_cache_status(response: Response, status: str) -> Response:99 from js import Response as JSResponse100101 updated = JSResponse.new(response.body, response.js_object)102 updated.headers.set("X-Diagram-Cache", status)103 return Response(updated)104105106class Default(WorkerEntrypoint):107 async def fetch(self, request: Request):108 if request.method == "OPTIONS":109 return Response(110 None,111 status=204,112 headers={113 "Access-Control-Allow-Headers": "Accept, Content-Type",114 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",115 "Access-Control-Allow-Origin": "*",116 "Access-Control-Max-Age": "86400",117 },118 )119120 engine = _engine(request)121 if engine not in MODULES:122 return _json_response({"error": {"code": "not_found", "message": "This renderer unit does not serve the requested engine."}}, status=404)123 path = urlparse(request.url).path124 build = self.env.RENDERER_BUILD125 if request.method == "GET" and path == "/v1/health":126 return _json_response({"ok": True, "unit": UNIT_ID, "id": engine})127 if request.method == "GET" and path == "/v1/capabilities":128 return _json_response({129 "unit": UNIT_ID,130 "id": engine,131 "kind": "render",132 "format": "svg",133 "runtime": "edge-python",134 "version": MODULES[engine].__version__,135 "build": build,136 "pipeline": [UNIT_ID],137 "knownLosses": [138 "Remote and filesystem-backed images are rejected.",139 "Only SVG output is supported.",140 ],141 }, cache_control="public, max-age=300")142 if request.method != "POST" or path != "/v1/svg":143 return _json_response({"error": {"code": "not_found", "message": "Route not found."}}, status=404)144 if not (request.headers.get("Content-Type") or "").lower().startswith("application/json"):145 return _error(ProtocolError(415, "unsupported_media_type", "Render requests must use application/json."))146147 try:148 render_input = parse_render_input(await _bounded_body(request))149 cache_key = None150 try:151 from js import Request as JSRequest, caches152153 cache_key = JSRequest.new(_cache_url(engine, build, render_input))154 cached = await caches.default.match(cache_key)155 if cached is not None:156 return _with_cache_status(Response(cached), "HIT")157 except Exception as error:158 print(json.dumps({"message": "BlockDiag cache read failed", "unit": UNIT_ID, "engine": engine, "error": str(error)}))159160 raw_svg, engine_version = render_svg(engine, render_input.source, render_input.options)161 svg = sanitize_and_decorate(raw_svg, render_input)162 except ProtocolError as error:163 return _error(error)164 except Exception as error:165 print(json.dumps({"message": "BlockDiag renderer failed", "unit": UNIT_ID, "engine": engine, "error": str(error)}))166 return _json_response({"error": {"code": "render_failed", "message": "The diagram source could not be rendered."}}, status=422)167168 response = Response(169 svg,170 headers={171 "Access-Control-Allow-Origin": "*",172 "Access-Control-Expose-Headers": EXPOSED_HEADERS,173 "Cache-Control": "public, max-age=1800",174 "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",175 "Content-Type": "image/svg+xml; charset=utf-8",176 "X-Content-Type-Options": "nosniff",177 "X-Diagram-Cache": "MISS",178 "X-Diagram-Engine": engine,179 "X-Diagram-Engine-Version": engine_version,180 "X-Diagram-Pipeline": UNIT_ID,181 "X-Diagram-Renderer": "edge-python",182 "X-Diagram-Unit": UNIT_ID,183 "X-Renderer-Build": build,184 },185 )186 if cache_key is not None:187 try:188 from js import caches189 from pyodide.ffi import create_proxy190191 self.ctx.waitUntil(create_proxy(caches.default.put(cache_key, response.js_object.clone())))192 except Exception as error:193 print(json.dumps({"message": "BlockDiag cache write failed", "unit": UNIT_ID, "engine": engine, "error": str(error)}))194 return response