Ten megabytes down to four, and a cold start that was a round trip
`http.server` compresses nothing, so dart2js output and the canvaskit wasm went over the wire raw -- 3.6MB and 6.9MB of the critical path, for files that gzip to a quarter of that. Compression happens once per file per container and is held in memory, which also stops re-reading them off a Volume mount that is a network filesystem, not a disk. Cache-Control by name rather than by hash, because Flutter web does not hash its filenames: the five files whose content changes every build revalidate, everything else gets an hour. An hour and not `immutable` is the honest bound when no URL here is safe to cache forever -- it is also how long a rebuild takes to reach someone who has been before. And the client metadata is compared before it is written. That `Volume.commit` sat on the cold-start path of every request arriving after a scaledown, for 540 bytes that change only when ORIGIN does. Measured on the deployed URL: main.dart.js 3,780,683 -> 1,086,725 bytes, canvaskit.wasm 6.9MB -> 2,919,782, and a small file off a cold container 5.76s -> 0.54s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ec5b47d parent: 480ff32 modified
.modal/flutter-web/serve.py +147 -15 | @@ -46,6 +46,33 @@ PORT = 8080 | ||
| 46 | 46 | # pasted images under /api/v1/media, and those are `:image` in the same chat. |
| 47 | 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") |
| 48 | 48 | |
| 49 | +# Files whose name is stable but whose *content* changes with every build, so a | |
| 50 | +# browser must ask before reusing one. Everything else -- `main.dart.js`, the | |
| 51 | +# canvaskit wasm, the assets -- gets a bounded max-age below: an hour of | |
| 52 | +# staleness in exchange for a repeat visit that costs no round trips. | |
| 53 | +# | |
| 54 | +# Why an hour and not `immutable`: Flutter web does not hash its filenames, so | |
| 55 | +# there is no URL that is safe to cache forever. The bound is the honest answer. | |
| 56 | +REVALIDATE = ( | |
| 57 | + "index.html", | |
| 58 | + "flutter_bootstrap.js", | |
| 59 | + "flutter_service_worker.js", | |
| 60 | + "version.json", | |
| 61 | + "client-metadata.json", | |
| 62 | +) | |
| 63 | + | |
| 64 | +MAX_AGE = 3600 | |
| 65 | + | |
| 66 | +# Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip | |
| 67 | +# on dart2js output and on wasm is the single biggest win this server has. | |
| 68 | +COMPRESSIBLE = ( | |
| 69 | + "text/", | |
| 70 | + "application/javascript", | |
| 71 | + "application/json", | |
| 72 | + "application/wasm", | |
| 73 | + "image/svg+xml", | |
| 74 | +) | |
| 75 | + | |
| 49 | 76 | # This app's own origin, and the one thing here that is not derivable: an |
| 50 | 77 | # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so |
| 51 | 78 | # the document has to name the origin it will be fetched from. Modal's |
| @@ -110,22 +137,53 @@ def web(): | ||
| 110 | 137 | # not baked into the build, because it names the deployed origin, which is |
| 111 | 138 | # a property of this Function and not of the ClojureDart. |
| 112 | 139 | # |
| 113 | - # The volume is shared and durable, so this also survives for the next | |
| 114 | - # cold start; rewriting it every time is what keeps ORIGIN and the file in | |
| 115 | - # step when one of them changes. | |
| 116 | - with open(f"{WEB_ROOT}/client-metadata.json", "w") as f: | |
| 117 | - json.dump(CLIENT_METADATA, f, indent=2) | |
| 118 | - DEVSHELL.commit() | |
| 140 | + # The volume is shared and durable, so this survives for the next cold | |
| 141 | + # start -- which is why writing it every time was waste. A `Volume.commit` | |
| 142 | + # is a network round trip, and this one sat on the cold-start path of every | |
| 143 | + # request that arrived after a scaledown, for a 540-byte file that changes | |
| 144 | + # only when ORIGIN does. So: compare first, and write only on a difference. | |
| 145 | + wanted = json.dumps(CLIENT_METADATA, indent=2) | |
| 146 | + meta = f"{WEB_ROOT}/client-metadata.json" | |
| 147 | + try: | |
| 148 | + current = open(meta).read() | |
| 149 | + except OSError: | |
| 150 | + current = None | |
| 151 | + if current != wanted: | |
| 152 | + with open(meta, "w") as f: | |
| 153 | + f.write(wanted) | |
| 154 | + DEVSHELL.commit() | |
| 119 | 155 | |
| 120 | 156 | # `http.server` with one route bolted on, rather than `-m http.server`: |
| 121 | 157 | # static files are still all the app wants on first load, but images need |
| 122 | 158 | # somewhere same-origin to come from. Written out and run as a file |
| 123 | 159 | # because `@modal.web_server` wants a process, not a handler object. |
| 124 | - server = f""" | |
| 125 | -import http.server, os, socketserver, urllib.parse, urllib.request | |
| 160 | + # Config injected as a prelude rather than interpolated through the source | |
| 161 | + # below: an f-string would need every literal brace in the program doubled, | |
| 162 | + # and the program has grown past the point where that is safe to edit. | |
| 163 | + prelude = ( | |
| 164 | + f"ROOT = {WEB_ROOT!r}\n" | |
| 165 | + f"HOSTS = {PROXY_HOSTS!r}\n" | |
| 166 | + f"PORT = {PORT}\n" | |
| 167 | + f"REVALIDATE = {REVALIDATE!r}\n" | |
| 168 | + f"MAX_AGE = {MAX_AGE}\n" | |
| 169 | + f"COMPRESSIBLE = {COMPRESSIBLE!r}\n" | |
| 170 | + ) | |
| 171 | + | |
| 172 | + # `http.server` with three things bolted on, rather than `-m http.server`: | |
| 173 | + # a same-origin route for images, gzip, and cache headers. Written out and | |
| 174 | + # run as a file because `@modal.web_server` wants a process, not a handler | |
| 175 | + # object. | |
| 176 | + server = prelude + ''' | |
| 177 | +import gzip, http.server, os, posixpath, socketserver, urllib.parse, urllib.request | |
| 178 | + | |
| 179 | +# path -> (mtime, content-type, gzipped bytes). Filled on first request for a | |
| 180 | +# file and reused for the life of the container, which does two things at once: | |
| 181 | +# it stops re-compressing the 3.6MB `main.dart.js` per request, and it stops | |
| 182 | +# re-reading it off the Volume mount, which is a network filesystem and not a | |
| 183 | +# local disk. A container serves one build, so mtime is all the invalidation | |
| 184 | +# this needs. | |
| 185 | +CACHE = {} | |
| 126 | 186 | |
| 127 | -ROOT = {WEB_ROOT!r} | |
| 128 | -HOSTS = {PROXY_HOSTS!r} | |
| 129 | 187 | |
| 130 | 188 | class H(http.server.SimpleHTTPRequestHandler): |
| 131 | 189 | def __init__(self, *a, **kw): |
| @@ -140,9 +198,81 @@ class H(http.server.SimpleHTTPRequestHandler): | ||
| 140 | 198 | self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") |
| 141 | 199 | self.end_headers() |
| 142 | 200 | |
| 201 | + def cache_control(self, path): | |
| 202 | + if os.path.basename(path) in REVALIDATE: | |
| 203 | + return "no-cache" | |
| 204 | + return f"public, max-age={MAX_AGE}" | |
| 205 | + | |
| 206 | + def entry(self, path): | |
| 207 | + """(content-type, gzipped body) for a file, compressed at most once.""" | |
| 208 | + mtime = os.path.getmtime(path) | |
| 209 | + hit = CACHE.get(path) | |
| 210 | + if hit and hit[0] == mtime: | |
| 211 | + return hit[1], hit[2] | |
| 212 | + ctype = self.guess_type(path) | |
| 213 | + with open(path, "rb") as f: | |
| 214 | + body = f.read() | |
| 215 | + # mtime=0 so the same input gives the same bytes; the gzip header's | |
| 216 | + # timestamp is noise no client reads. | |
| 217 | + body = gzip.compress(body, compresslevel=6, mtime=0) | |
| 218 | + CACHE[path] = (mtime, ctype, body) | |
| 219 | + return ctype, body | |
| 220 | + | |
| 143 | 221 | def do_GET(self): |
| 144 | - if not self.path.startswith("/proxy?"): | |
| 222 | + if self.path.startswith("/proxy?"): | |
| 223 | + return self.proxy() | |
| 224 | + | |
| 225 | + path = self.translate_path(self.path) | |
| 226 | + if os.path.isdir(path): | |
| 227 | + path = os.path.join(path, "index.html") | |
| 228 | + | |
| 229 | + # Anything we cannot compress -- a missing file, an image, a client | |
| 230 | + # that did not ask for gzip -- falls back to the stock handler, which | |
| 231 | + # already does ranges, 304s and 404s correctly. | |
| 232 | + ctype = self.guess_type(path) if os.path.isfile(path) else "" | |
| 233 | + if ( | |
| 234 | + not os.path.isfile(path) | |
| 235 | + or "gzip" not in self.headers.get("Accept-Encoding", "") | |
| 236 | + or not ctype.startswith(COMPRESSIBLE) | |
| 237 | + ): | |
| 238 | + return self.stock_get(path) | |
| 239 | + | |
| 240 | + try: | |
| 241 | + ctype, body = self.entry(path) | |
| 242 | + except OSError: | |
| 243 | + return self.stock_get(path) | |
| 244 | + | |
| 245 | + self.send_response(200) | |
| 246 | + self.send_header("Content-Type", ctype) | |
| 247 | + self.send_header("Content-Encoding", "gzip") | |
| 248 | + self.send_header("Content-Length", str(len(body))) | |
| 249 | + self.send_header("Vary", "Accept-Encoding") | |
| 250 | + self.send_header("Cache-Control", self.cache_control(path)) | |
| 251 | + self.end_headers() | |
| 252 | + self.wfile.write(body) | |
| 253 | + | |
| 254 | + def stock_get(self, path): | |
| 255 | + """SimpleHTTPRequestHandler's own GET, with our Cache-Control added.""" | |
| 256 | + # Only a real file gets a Cache-Control; a 404 must not be cached for | |
| 257 | + # an hour just because it went through here. | |
| 258 | + self._cc = self.cache_control(path) if os.path.isfile(path) else None | |
| 259 | + try: | |
| 145 | 260 | return super().do_GET() |
| 261 | + finally: | |
| 262 | + self._cc = None | |
| 263 | + | |
| 264 | + def send_header(self, key, value): | |
| 265 | + super().send_header(key, value) | |
| 266 | + # The stock handler ends its headers itself, so there is no seam to add | |
| 267 | + # one -- except here, riding along with the header it always sends. | |
| 268 | + # `Content-type`, lowercase t: that is the spelling the stock handler | |
| 269 | + # uses, and matching it case-sensitively is how this silently did | |
| 270 | + # nothing the first time. | |
| 271 | + if key.lower() == "content-type" and getattr(self, "_cc", None): | |
| 272 | + cc, self._cc = self._cc, None | |
| 273 | + super().send_header("Cache-Control", cc) | |
| 274 | + | |
| 275 | + def proxy(self): | |
| 146 | 276 | q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) |
| 147 | 277 | target = (q.get("url") or [""])[0] |
| 148 | 278 | parts = urllib.parse.urlparse(target) |
| @@ -152,12 +282,12 @@ class H(http.server.SimpleHTTPRequestHandler): | ||
| 152 | 282 | self.send_error(403, "host not proxied") |
| 153 | 283 | return |
| 154 | 284 | try: |
| 155 | - req = urllib.request.Request(target, headers={{"user-agent": "frq"}}) | |
| 285 | + req = urllib.request.Request(target, headers={"user-agent": "frq"}) | |
| 156 | 286 | with urllib.request.urlopen(req, timeout=30) as up: |
| 157 | 287 | body = up.read() |
| 158 | 288 | ctype = up.headers.get("content-type", "application/octet-stream") |
| 159 | 289 | except Exception as e: |
| 160 | - self.send_error(502, f"upstream: {{e}}") | |
| 290 | + self.send_error(502, f"upstream: {e}") | |
| 161 | 291 | return |
| 162 | 292 | self.send_response(200) |
| 163 | 293 | self.send_header("Content-Type", ctype) |
| @@ -168,14 +298,16 @@ class H(http.server.SimpleHTTPRequestHandler): | ||
| 168 | 298 | self.end_headers() |
| 169 | 299 | self.wfile.write(body) |
| 170 | 300 | |
| 301 | + | |
| 171 | 302 | class S(socketserver.ThreadingTCPServer): |
| 172 | 303 | # Threaded, because a proxied fetch blocks: one slow avatar must not stop |
| 173 | 304 | # the page loading. daemon_threads so the process can still exit. |
| 174 | 305 | allow_reuse_address = True |
| 175 | 306 | daemon_threads = True |
| 176 | 307 | |
| 177 | -S(("", {PORT}), H).serve_forever() | |
| 178 | -""" | |
| 308 | + | |
| 309 | +S(("", PORT), H).serve_forever() | |
| 310 | +''' | |
| 179 | 311 | with open("/tmp/frq_serve.py", "w") as f: |
| 180 | 312 | f.write(server) |
| 181 | 313 | |
| @@ -46,6 +46,33 @@ PORT = 8080 | |||
| 46 | # pasted images under /api/v1/media, and those are `:image` in the same chat. | 46 | # pasted images under /api/v1/media, and those are `:image` in the same chat. |
| 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") | 47 | PROXY_HOSTS = ("cdn.bsky.app", "irc.freeq.at", "video.bsky.app") |
| 48 | 48 | ||
| 49 | +# Files whose name is stable but whose *content* changes with every build, so a | ||
| 50 | +# browser must ask before reusing one. Everything else -- `main.dart.js`, the | ||
| 51 | +# canvaskit wasm, the assets -- gets a bounded max-age below: an hour of | ||
| 52 | +# staleness in exchange for a repeat visit that costs no round trips. | ||
| 53 | +# | ||
| 54 | +# Why an hour and not `immutable`: Flutter web does not hash its filenames, so | ||
| 55 | +# there is no URL that is safe to cache forever. The bound is the honest answer. | ||
| 56 | +REVALIDATE = ( | ||
| 57 | + "index.html", | ||
| 58 | + "flutter_bootstrap.js", | ||
| 59 | + "flutter_service_worker.js", | ||
| 60 | + "version.json", | ||
| 61 | + "client-metadata.json", | ||
| 62 | +) | ||
| 63 | + | ||
| 64 | +MAX_AGE = 3600 | ||
| 65 | + | ||
| 66 | +# Content types worth compressing. gzip on a PNG spends CPU to add bytes; gzip | ||
| 67 | +# on dart2js output and on wasm is the single biggest win this server has. | ||
| 68 | +COMPRESSIBLE = ( | ||
| 69 | + "text/", | ||
| 70 | + "application/javascript", | ||
| 71 | + "application/json", | ||
| 72 | + "application/wasm", | ||
| 73 | + "image/svg+xml", | ||
| 74 | +) | ||
| 75 | + | ||
| 49 | # This app's own origin, and the one thing here that is not derivable: an | 76 | # This app's own origin, and the one thing here that is not derivable: an |
| 50 | # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so | 77 | # OAuth client_id in AT Protocol *is* the URL its metadata is served from, so |
| 51 | # the document has to name the origin it will be fetched from. Modal's | 78 | # the document has to name the origin it will be fetched from. Modal's |
| @@ -110,22 +137,53 @@ def web(): | |||
| 110 | # not baked into the build, because it names the deployed origin, which is | 137 | # not baked into the build, because it names the deployed origin, which is |
| 111 | # a property of this Function and not of the ClojureDart. | 138 | # a property of this Function and not of the ClojureDart. |
| 112 | # | 139 | # |
| 113 | - # The volume is shared and durable, so this also survives for the next | 140 | + # The volume is shared and durable, so this survives for the next cold |
| 114 | - # cold start; rewriting it every time is what keeps ORIGIN and the file in | 141 | + # start -- which is why writing it every time was waste. A `Volume.commit` |
| 115 | - # step when one of them changes. | 142 | + # is a network round trip, and this one sat on the cold-start path of every |
| 116 | - with open(f"{WEB_ROOT}/client-metadata.json", "w") as f: | 143 | + # request that arrived after a scaledown, for a 540-byte file that changes |
| 117 | - json.dump(CLIENT_METADATA, f, indent=2) | 144 | + # only when ORIGIN does. So: compare first, and write only on a difference. |
| 118 | - DEVSHELL.commit() | 145 | + wanted = json.dumps(CLIENT_METADATA, indent=2) |
| 146 | + meta = f"{WEB_ROOT}/client-metadata.json" | ||
| 147 | + try: | ||
| 148 | + current = open(meta).read() | ||
| 149 | + except OSError: | ||
| 150 | + current = None | ||
| 151 | + if current != wanted: | ||
| 152 | + with open(meta, "w") as f: | ||
| 153 | + f.write(wanted) | ||
| 154 | + DEVSHELL.commit() | ||
| 119 | 155 | ||
| 120 | # `http.server` with one route bolted on, rather than `-m http.server`: | 156 | # `http.server` with one route bolted on, rather than `-m http.server`: |
| 121 | # static files are still all the app wants on first load, but images need | 157 | # static files are still all the app wants on first load, but images need |
| 122 | # somewhere same-origin to come from. Written out and run as a file | 158 | # somewhere same-origin to come from. Written out and run as a file |
| 123 | # because `@modal.web_server` wants a process, not a handler object. | 159 | # because `@modal.web_server` wants a process, not a handler object. |
| 124 | - server = f""" | 160 | + # Config injected as a prelude rather than interpolated through the source |
| 125 | -import http.server, os, socketserver, urllib.parse, urllib.request | 161 | + # below: an f-string would need every literal brace in the program doubled, |
| 162 | + # and the program has grown past the point where that is safe to edit. | ||
| 163 | + prelude = ( | ||
| 164 | + f"ROOT = {WEB_ROOT!r}\n" | ||
| 165 | + f"HOSTS = {PROXY_HOSTS!r}\n" | ||
| 166 | + f"PORT = {PORT}\n" | ||
| 167 | + f"REVALIDATE = {REVALIDATE!r}\n" | ||
| 168 | + f"MAX_AGE = {MAX_AGE}\n" | ||
| 169 | + f"COMPRESSIBLE = {COMPRESSIBLE!r}\n" | ||
| 170 | + ) | ||
| 171 | + | ||
| 172 | + # `http.server` with three things bolted on, rather than `-m http.server`: | ||
| 173 | + # a same-origin route for images, gzip, and cache headers. Written out and | ||
| 174 | + # run as a file because `@modal.web_server` wants a process, not a handler | ||
| 175 | + # object. | ||
| 176 | + server = prelude + ''' | ||
| 177 | +import gzip, http.server, os, posixpath, socketserver, urllib.parse, urllib.request | ||
| 178 | + | ||
| 179 | +# path -> (mtime, content-type, gzipped bytes). Filled on first request for a | ||
| 180 | +# file and reused for the life of the container, which does two things at once: | ||
| 181 | +# it stops re-compressing the 3.6MB `main.dart.js` per request, and it stops | ||
| 182 | +# re-reading it off the Volume mount, which is a network filesystem and not a | ||
| 183 | +# local disk. A container serves one build, so mtime is all the invalidation | ||
| 184 | +# this needs. | ||
| 185 | +CACHE = {} | ||
| 126 | 186 | ||
| 127 | -ROOT = {WEB_ROOT!r} | ||
| 128 | -HOSTS = {PROXY_HOSTS!r} | ||
| 129 | 187 | ||
| 130 | class H(http.server.SimpleHTTPRequestHandler): | 188 | class H(http.server.SimpleHTTPRequestHandler): |
| 131 | def __init__(self, *a, **kw): | 189 | def __init__(self, *a, **kw): |
| @@ -140,9 +198,81 @@ class H(http.server.SimpleHTTPRequestHandler): | |||
| 140 | self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") | 198 | self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") |
| 141 | self.end_headers() | 199 | self.end_headers() |
| 142 | 200 | ||
| 201 | + def cache_control(self, path): | ||
| 202 | + if os.path.basename(path) in REVALIDATE: | ||
| 203 | + return "no-cache" | ||
| 204 | + return f"public, max-age={MAX_AGE}" | ||
| 205 | + | ||
| 206 | + def entry(self, path): | ||
| 207 | + """(content-type, gzipped body) for a file, compressed at most once.""" | ||
| 208 | + mtime = os.path.getmtime(path) | ||
| 209 | + hit = CACHE.get(path) | ||
| 210 | + if hit and hit[0] == mtime: | ||
| 211 | + return hit[1], hit[2] | ||
| 212 | + ctype = self.guess_type(path) | ||
| 213 | + with open(path, "rb") as f: | ||
| 214 | + body = f.read() | ||
| 215 | + # mtime=0 so the same input gives the same bytes; the gzip header's | ||
| 216 | + # timestamp is noise no client reads. | ||
| 217 | + body = gzip.compress(body, compresslevel=6, mtime=0) | ||
| 218 | + CACHE[path] = (mtime, ctype, body) | ||
| 219 | + return ctype, body | ||
| 220 | + | ||
| 143 | def do_GET(self): | 221 | def do_GET(self): |
| 144 | - if not self.path.startswith("/proxy?"): | 222 | + if self.path.startswith("/proxy?"): |
| 223 | + return self.proxy() | ||
| 224 | + | ||
| 225 | + path = self.translate_path(self.path) | ||
| 226 | + if os.path.isdir(path): | ||
| 227 | + path = os.path.join(path, "index.html") | ||
| 228 | + | ||
| 229 | + # Anything we cannot compress -- a missing file, an image, a client | ||
| 230 | + # that did not ask for gzip -- falls back to the stock handler, which | ||
| 231 | + # already does ranges, 304s and 404s correctly. | ||
| 232 | + ctype = self.guess_type(path) if os.path.isfile(path) else "" | ||
| 233 | + if ( | ||
| 234 | + not os.path.isfile(path) | ||
| 235 | + or "gzip" not in self.headers.get("Accept-Encoding", "") | ||
| 236 | + or not ctype.startswith(COMPRESSIBLE) | ||
| 237 | + ): | ||
| 238 | + return self.stock_get(path) | ||
| 239 | + | ||
| 240 | + try: | ||
| 241 | + ctype, body = self.entry(path) | ||
| 242 | + except OSError: | ||
| 243 | + return self.stock_get(path) | ||
| 244 | + | ||
| 245 | + self.send_response(200) | ||
| 246 | + self.send_header("Content-Type", ctype) | ||
| 247 | + self.send_header("Content-Encoding", "gzip") | ||
| 248 | + self.send_header("Content-Length", str(len(body))) | ||
| 249 | + self.send_header("Vary", "Accept-Encoding") | ||
| 250 | + self.send_header("Cache-Control", self.cache_control(path)) | ||
| 251 | + self.end_headers() | ||
| 252 | + self.wfile.write(body) | ||
| 253 | + | ||
| 254 | + def stock_get(self, path): | ||
| 255 | + """SimpleHTTPRequestHandler's own GET, with our Cache-Control added.""" | ||
| 256 | + # Only a real file gets a Cache-Control; a 404 must not be cached for | ||
| 257 | + # an hour just because it went through here. | ||
| 258 | + self._cc = self.cache_control(path) if os.path.isfile(path) else None | ||
| 259 | + try: | ||
| 145 | return super().do_GET() | 260 | return super().do_GET() |
| 261 | + finally: | ||
| 262 | + self._cc = None | ||
| 263 | + | ||
| 264 | + def send_header(self, key, value): | ||
| 265 | + super().send_header(key, value) | ||
| 266 | + # The stock handler ends its headers itself, so there is no seam to add | ||
| 267 | + # one -- except here, riding along with the header it always sends. | ||
| 268 | + # `Content-type`, lowercase t: that is the spelling the stock handler | ||
| 269 | + # uses, and matching it case-sensitively is how this silently did | ||
| 270 | + # nothing the first time. | ||
| 271 | + if key.lower() == "content-type" and getattr(self, "_cc", None): | ||
| 272 | + cc, self._cc = self._cc, None | ||
| 273 | + super().send_header("Cache-Control", cc) | ||
| 274 | + | ||
| 275 | + def proxy(self): | ||
| 146 | q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) | 276 | q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) |
| 147 | target = (q.get("url") or [""])[0] | 277 | target = (q.get("url") or [""])[0] |
| 148 | parts = urllib.parse.urlparse(target) | 278 | parts = urllib.parse.urlparse(target) |
| @@ -152,12 +282,12 @@ class H(http.server.SimpleHTTPRequestHandler): | |||
| 152 | self.send_error(403, "host not proxied") | 282 | self.send_error(403, "host not proxied") |
| 153 | return | 283 | return |
| 154 | try: | 284 | try: |
| 155 | - req = urllib.request.Request(target, headers={{"user-agent": "frq"}}) | 285 | + req = urllib.request.Request(target, headers={"user-agent": "frq"}) |
| 156 | with urllib.request.urlopen(req, timeout=30) as up: | 286 | with urllib.request.urlopen(req, timeout=30) as up: |
| 157 | body = up.read() | 287 | body = up.read() |
| 158 | ctype = up.headers.get("content-type", "application/octet-stream") | 288 | ctype = up.headers.get("content-type", "application/octet-stream") |
| 159 | except Exception as e: | 289 | except Exception as e: |
| 160 | - self.send_error(502, f"upstream: {{e}}") | 290 | + self.send_error(502, f"upstream: {e}") |
| 161 | return | 291 | return |
| 162 | self.send_response(200) | 292 | self.send_response(200) |
| 163 | self.send_header("Content-Type", ctype) | 293 | self.send_header("Content-Type", ctype) |
| @@ -168,14 +298,16 @@ class H(http.server.SimpleHTTPRequestHandler): | |||
| 168 | self.end_headers() | 298 | self.end_headers() |
| 169 | self.wfile.write(body) | 299 | self.wfile.write(body) |
| 170 | 300 | ||
| 301 | + | ||
| 171 | class S(socketserver.ThreadingTCPServer): | 302 | class S(socketserver.ThreadingTCPServer): |
| 172 | # Threaded, because a proxied fetch blocks: one slow avatar must not stop | 303 | # Threaded, because a proxied fetch blocks: one slow avatar must not stop |
| 173 | # the page loading. daemon_threads so the process can still exit. | 304 | # the page loading. daemon_threads so the process can still exit. |
| 174 | allow_reuse_address = True | 305 | allow_reuse_address = True |
| 175 | daemon_threads = True | 306 | daemon_threads = True |
| 176 | 307 | ||
| 177 | -S(("", {PORT}), H).serve_forever() | 308 | + |
| 178 | -""" | 309 | +S(("", PORT), H).serve_forever() |
| 310 | +''' | ||
| 179 | with open("/tmp/frq_serve.py", "w") as f: | 311 | with open("/tmp/frq_serve.py", "w") as f: |
| 180 | f.write(server) | 312 | f.write(server) |
| 181 | 313 | ||