stackthread 0.1.0.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of stackthread might be problematic. Click here for more details.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,696 @@
1
+ """HTTP server on the stdlib: routes, JSON API, server-sent events, static UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import json
7
+ import mimetypes
8
+ import queue
9
+ import re
10
+ import sys
11
+ import threading
12
+ import time
13
+ import webbrowser
14
+ from http import HTTPStatus
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from urllib.parse import parse_qs, urlsplit
19
+
20
+ from stackthread import SCHEMA_VERSION, __version__, adapters, lineage, logs, report
21
+ from stackthread.project import Poller, Project, Snapshot
22
+ from stackthread.server import edit, security
23
+
24
+ PREFIX = "/__stackthread"
25
+ _log = logs.get("server")
26
+ _http = logs.get("http")
27
+ _edit = logs.get("edit")
28
+ _client = logs.get("client")
29
+ CLIENT_SOURCES = ("ui", "overlay", "vite")
30
+ CLIENT_LEVELS = {"error": 40, "warning": 30, "info": 20}
31
+ CLIENT_PER_MINUTE = 60
32
+ SLOW_LINEAGE_MS = 1000
33
+ STATIC = Path(__file__).resolve().parents[1] / "static"
34
+ MAX_BODY = 4 * 1024 * 1024
35
+ EDITOR_URLS = {
36
+ "vscode": "vscode://file/{path}:{line}:{col}",
37
+ "cursor": "cursor://file/{path}:{line}:{col}",
38
+ "windsurf": "windsurf://file/{path}:{line}:{col}",
39
+ "zed": "zed://file/{path}:{line}:{col}",
40
+ "idea": "idea://open?file={path}&line={line}",
41
+ "pycharm": "pycharm://open?file={path}&line={line}",
42
+ "webstorm": "webstorm://open?file={path}&line={line}",
43
+ "none": "",
44
+ }
45
+
46
+
47
+ class Events:
48
+ """Fan-out of server-sent events to every open UI."""
49
+
50
+ def __init__(self) -> None:
51
+ self._clients: list[queue.Queue[str]] = []
52
+ self._lock = threading.Lock()
53
+
54
+ def publish(self, kind: str, data: dict[str, Any]) -> None:
55
+ message = f"event: {kind}\ndata: {json.dumps(data)}\n\n"
56
+ with self._lock:
57
+ for q in self._clients:
58
+ q.put(message)
59
+
60
+ def connect(self) -> queue.Queue[str]:
61
+ q: queue.Queue[str] = queue.Queue(maxsize=200)
62
+ with self._lock:
63
+ self._clients.append(q)
64
+ return q
65
+
66
+ def disconnect(self, q: queue.Queue[str]) -> None:
67
+ with self._lock:
68
+ if q in self._clients:
69
+ self._clients.remove(q)
70
+
71
+
72
+ class ClientLimiter:
73
+ """At most CLIENT_PER_MINUTE messages a minute from each browser source; the rest are counted, then reported."""
74
+
75
+ def __init__(self) -> None:
76
+ self._sent: dict[str, collections.deque[float]] = collections.defaultdict(collections.deque)
77
+ self._dropped: dict[str, int] = collections.defaultdict(int)
78
+ self._lock = threading.Lock()
79
+
80
+ def allow(self, source: str) -> tuple[bool, int]:
81
+ """Whether to log this one, and how many were dropped since the last one that was."""
82
+ now = time.monotonic()
83
+ with self._lock:
84
+ sent = self._sent[source]
85
+ while sent and now - sent[0] > 60:
86
+ sent.popleft()
87
+ if len(sent) >= CLIENT_PER_MINUTE:
88
+ self._dropped[source] += 1
89
+ return False, 0
90
+ sent.append(now)
91
+ dropped, self._dropped[source] = self._dropped[source], 0
92
+ return True, dropped
93
+
94
+
95
+ class App:
96
+ def __init__(self, project: Project, port: int, token: str) -> None:
97
+ self.project = project
98
+ self.port = port
99
+ self.token = token
100
+ self.events = Events()
101
+ self.dev_urls: list[str] = []
102
+ self.limiter = ClientLimiter()
103
+ self._last_progress = 0.0
104
+ logs.hide(token)
105
+ project.subscribe(self._on_snapshot)
106
+ project.on_activity(self._on_activity)
107
+
108
+ @property
109
+ def snapshot(self) -> Snapshot:
110
+ if self.project.snapshot is None:
111
+ raise edit.EditError(503, "stackthread is still reading the code; try again in a moment")
112
+ return self.project.snapshot
113
+
114
+ def _on_activity(self, activity: dict[str, Any]) -> None:
115
+ # progress while indexing is throttled to ten a second; every change of state goes out at once
116
+ now = time.monotonic()
117
+ in_progress = activity.get("state") == "indexing" and 0 < activity.get("done", 0) < activity.get("total", 0)
118
+ if in_progress and now - self._last_progress < 0.1:
119
+ return
120
+ self._last_progress = now
121
+ self.events.publish("activity", activity)
122
+
123
+ def _on_snapshot(self, snap: Snapshot) -> None:
124
+ self.events.publish(
125
+ "rev",
126
+ {
127
+ "rev": snap.rev,
128
+ "changed": snap.changed[:50],
129
+ "ms": round(snap.built_ms),
130
+ "findings": len(snap.graph.findings),
131
+ },
132
+ )
133
+
134
+ # ---- API ----
135
+
136
+ def meta(self) -> dict[str, Any]:
137
+ cfg, snap = self.project.config, self.project.snapshot
138
+ if snap is None:
139
+ return {
140
+ "name": cfg.name,
141
+ "version": __version__,
142
+ "schema": SCHEMA_VERSION,
143
+ "ready": False,
144
+ "activity": self.project.activity,
145
+ }
146
+ return {
147
+ "name": cfg.name,
148
+ "version": __version__,
149
+ "schema": SCHEMA_VERSION,
150
+ "ready": True,
151
+ "activity": self.project.activity,
152
+ "rev": snap.rev,
153
+ "root": str(cfg.root),
154
+ "platform": sys.platform,
155
+ "editor": {"name": cfg.server.editor, "url": EDITOR_URLS.get(cfg.server.editor, "")},
156
+ "edit": cfg.server.edit,
157
+ "detected": cfg.detected,
158
+ "config": str(cfg.path) if cfg.path else None,
159
+ "sources": [
160
+ {
161
+ "name": s.name,
162
+ "lang": s.lang,
163
+ "role": s.role,
164
+ "root": s.root,
165
+ "files": len(snap.facts.get(s.name, {})),
166
+ }
167
+ for s in cfg.sources
168
+ ],
169
+ "warnings": snap.warnings,
170
+ "devUrls": self.dev_urls,
171
+ "counts": {
172
+ k: sum(1 for n in snap.graph.nodes.values() if n.kind == k)
173
+ for k in ("page", "component", "function", "hook", "endpoint", "model", "class", "method")
174
+ },
175
+ }
176
+
177
+ def node(self, node_id: str) -> dict[str, Any]:
178
+ g = self.snapshot.graph
179
+ node = g.nodes.get(node_id)
180
+ if node is None:
181
+ raise edit.EditError(404, "no such node")
182
+
183
+ def side(e: Any, other: str) -> dict[str, Any]:
184
+ n = g.nodes[other]
185
+ data = {
186
+ "id": n.id,
187
+ "kind": n.kind,
188
+ "name": n.name,
189
+ "qualname": n.qualname,
190
+ "file": n.file,
191
+ "line": n.line,
192
+ "layer": n.layer,
193
+ "edge": e.kind,
194
+ "confidence": e.confidence,
195
+ "label": e.label,
196
+ }
197
+ if e.site:
198
+ data["site"] = f"{e.site.file}:{e.site.line}"
199
+ return data
200
+
201
+ return {
202
+ "node": node.to_json(),
203
+ "out": [side(e, e.dst) for e in g.out(node_id) if e.kind != "contains"],
204
+ "in": [side(e, e.src) for e in g.into(node_id) if e.kind != "contains"],
205
+ "children": [side(e, e.dst) for e in g.out(node_id, "contains")],
206
+ "parent": next((side(e, e.src) for e in g.into(node_id, "contains")), None),
207
+ }
208
+
209
+ def search(self, q: str, limit: int) -> list[dict[str, Any]]:
210
+ q = q.strip().lower()
211
+ if not q:
212
+ return []
213
+ scored = []
214
+ for n in self.snapshot.graph.nodes.values():
215
+ name, qual, file = n.name.lower(), (n.qualname or "").lower(), n.file.lower()
216
+ if name == q:
217
+ score = 0
218
+ elif name.startswith(q):
219
+ score = 1
220
+ elif q in name:
221
+ score = 2
222
+ elif q in qual:
223
+ score = 3
224
+ elif q in file:
225
+ score = 4
226
+ else:
227
+ continue
228
+ if "test" in n.tags:
229
+ score += 3
230
+ scored.append((score, len(n.name), n))
231
+ scored.sort(key=lambda x: (x[0], x[1]))
232
+ return [
233
+ {
234
+ "id": n.id,
235
+ "kind": n.kind,
236
+ "name": n.name,
237
+ "qualname": n.qualname,
238
+ "file": n.file,
239
+ "line": n.line,
240
+ "layer": n.layer,
241
+ "source": n.source,
242
+ }
243
+ for _, _, n in scored[:limit]
244
+ ]
245
+
246
+ def pages(self) -> dict[str, Any]:
247
+ g = self.snapshot.graph
248
+ pages = []
249
+ for p in sorted(g.of_kind("page"), key=lambda n: n.attrs.get("path", "")):
250
+ comp = g.nodes.get(p.attrs.get("component", ""))
251
+ pages.append(
252
+ {
253
+ "id": p.id,
254
+ "path": p.attrs.get("path"),
255
+ "source": p.source,
256
+ "file": p.file,
257
+ "line": p.line,
258
+ "component": comp.name if comp else None,
259
+ "componentId": comp.id if comp else None,
260
+ }
261
+ )
262
+ views = [v.to_json() for v in adapters.views(self.project)]
263
+ return {"pages": pages, "views": views, "params": self.project.config.page_params}
264
+
265
+ def findings(self) -> list[dict[str, Any]]:
266
+ order = {"error": 0, "warning": 1, "info": 2}
267
+ return [f.to_json() for f in sorted(self.snapshot.graph.findings, key=lambda f: (order[f.severity], f.file))]
268
+
269
+
270
+ def _json(handler: BaseHTTPRequestHandler, data: Any, status: int = 200) -> None:
271
+ body = json.dumps(data, separators=(",", ":")).encode("utf-8")
272
+ handler.send_response(status)
273
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
274
+ handler.send_header("Content-Length", str(len(body)))
275
+ handler.send_header("Cache-Control", "no-store")
276
+ handler.end_headers()
277
+ handler.wfile.write(body)
278
+
279
+
280
+ def make_handler(app: App) -> type[BaseHTTPRequestHandler]:
281
+ class Handler(BaseHTTPRequestHandler):
282
+ server_version = f"stackthread/{__version__}"
283
+ protocol_version = "HTTP/1.1"
284
+
285
+ def parse_request(self) -> bool:
286
+ self._started = time.perf_counter()
287
+ return super().parse_request()
288
+
289
+ def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
290
+ status = int(code) if str(code).isdigit() else 0
291
+ path = re.sub(r"([?&]token=)[^&]*", r"\1[hidden]", getattr(self, "path", ""))
292
+ ms = (time.perf_counter() - getattr(self, "_started", time.perf_counter())) * 1000
293
+ level = 40 if status >= 500 else 10
294
+ _http.log(
295
+ level,
296
+ "%s %s %s %.0f ms",
297
+ self.command,
298
+ path,
299
+ status,
300
+ ms,
301
+ extra=logs.ctx(method=self.command, path=path, status=status, ms=round(ms, 1)),
302
+ )
303
+
304
+ def log_error(self, format: str, *args: Any) -> None:
305
+ _http.debug(format, *args)
306
+
307
+ def log_message(self, format: str, *args: Any) -> None:
308
+ _http.debug(format, *args)
309
+
310
+ def _security_headers(self) -> None:
311
+ self.send_header("X-Content-Type-Options", "nosniff")
312
+ self.send_header("Referrer-Policy", "no-referrer")
313
+
314
+ def end_headers(self) -> None:
315
+ self._security_headers()
316
+ super().end_headers()
317
+
318
+ def _fail(self, status: int, message: str) -> None:
319
+ if self.command in ("POST", "PUT"):
320
+ self.close_connection = True # an unread body must not be parsed as the next request
321
+ _json(self, {"error": message}, status)
322
+
323
+ def _refuse(self, status: int, reason: str, **fields: Any) -> bool:
324
+ _http.warning(
325
+ "refused %s %s: %s",
326
+ self.command,
327
+ urlsplit(self.path).path,
328
+ reason,
329
+ extra=logs.ctx(status=status, **fields),
330
+ )
331
+ self._fail(status, reason)
332
+ return False
333
+
334
+ def _guard(self, needs_token: bool, query: dict[str, list[str]]) -> bool:
335
+ if not security.host_allowed(self.headers.get("Host"), app.port):
336
+ return self._refuse(HTTPStatus.MISDIRECTED_REQUEST, "bad Host header", host=self.headers.get("Host"))
337
+ if not security.origin_allowed(self.headers.get("Origin")):
338
+ return self._refuse(
339
+ HTTPStatus.FORBIDDEN, "cross-origin requests are not allowed", origin=self.headers.get("Origin")
340
+ )
341
+ if needs_token:
342
+ given = self.headers.get(security.TOKEN_HEADER) or query.get("token", [""])[0]
343
+ if not security.token_ok(app.token, given):
344
+ return self._refuse(HTTPStatus.UNAUTHORIZED, "missing or wrong session token")
345
+ return True
346
+
347
+ def _body(self) -> Any:
348
+ length = int(self.headers.get("Content-Length") or 0)
349
+ if length > MAX_BODY:
350
+ raise edit.EditError(413, "request body too large")
351
+ if not (self.headers.get("Content-Type") or "").startswith("application/json"):
352
+ raise edit.EditError(415, "send application/json")
353
+ return json.loads(self.rfile.read(length) or b"{}")
354
+
355
+ # ---- routing ----
356
+
357
+ def do_GET(self) -> None:
358
+ url = urlsplit(self.path)
359
+ query = parse_qs(url.query)
360
+ path = url.path
361
+ if path in ("/", ""):
362
+ if not self._guard(False, query):
363
+ return
364
+ self.send_response(302)
365
+ self.send_header("Location", f"{PREFIX}/")
366
+ self.send_header("Content-Length", "0")
367
+ self.end_headers()
368
+ return
369
+ if not path.startswith(PREFIX):
370
+ self._fail(404, "not found")
371
+ return
372
+ sub = path[len(PREFIX) :] or "/"
373
+ if sub.startswith("/api/"):
374
+ if not self._guard(True, query):
375
+ return
376
+ try:
377
+ self._api_get(sub[5:], query)
378
+ except edit.EditError as e:
379
+ if e.status not in (404, 503):
380
+ _http.warning("GET %s: %s", sub, e.message, extra=logs.ctx(status=e.status))
381
+ self._fail(e.status, e.message)
382
+ except Exception as e: # report instead of dropping the connection
383
+ _http.exception("GET %s failed", sub)
384
+ self._fail(500, f"{type(e).__name__}: {e}")
385
+ return
386
+ if not self._guard(False, query):
387
+ return
388
+ self._static(sub)
389
+
390
+ def do_POST(self) -> None:
391
+ self._mutate("POST")
392
+
393
+ def do_PUT(self) -> None:
394
+ self._mutate("PUT")
395
+
396
+ def _mutate(self, method: str) -> None:
397
+ url = urlsplit(self.path)
398
+ query = parse_qs(url.query)
399
+ sub = url.path[len(PREFIX) :] if url.path.startswith(PREFIX) else ""
400
+ if not sub.startswith("/api/"):
401
+ self._fail(404, "not found")
402
+ return
403
+ if not self._guard(True, query):
404
+ return
405
+ try:
406
+ body = self._body()
407
+ name = sub[5:]
408
+ if method == "POST" and name == "lineage":
409
+ started = time.perf_counter()
410
+ result = lineage.for_selection(app.project, app.snapshot, body)
411
+ self._log_lineage(result, started)
412
+ _json(self, result)
413
+ elif method == "PUT" and name == "source":
414
+ file = str(body.get("file", ""))
415
+ try:
416
+ written = edit.write(
417
+ app.project.config, file, str(body.get("hash", "")), str(body.get("text", ""))
418
+ )
419
+ except edit.EditError as e:
420
+ _edit.warning(
421
+ "did not save %s: %s", file, e.message, extra=logs.ctx(file=file, status=e.status)
422
+ )
423
+ raise
424
+ _edit.info(
425
+ "saved %s%s",
426
+ file,
427
+ " and formatted it" if written.formatted else "",
428
+ extra=logs.ctx(file=file, formatted=written.formatted),
429
+ )
430
+ if written.output and not written.formatted:
431
+ _edit.warning("the formatter reported a problem with %s: %s", file, written.output[-500:])
432
+ app.project.invalidate([file])
433
+ threading.Thread(target=app.project.poll, name="stackthread-after-save", daemon=True).start()
434
+ _json(self, {"hash": written.hash, "formatted": written.formatted, "output": written.output})
435
+ elif method == "POST" and name == "hello":
436
+ dev = str(body.get("devUrl", ""))
437
+ if (
438
+ re.match(r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/?$", dev)
439
+ and dev.rstrip("/") not in app.dev_urls
440
+ ):
441
+ app.dev_urls.append(dev.rstrip("/"))
442
+ _log.info("the Vite dev server at %s connected", dev, extra=logs.ctx(dev_url=dev))
443
+ _json(self, {"ok": True, "name": app.project.config.name})
444
+ elif method == "POST" and name == "log":
445
+ self._client_log(body)
446
+ elif method == "POST" and name == "report":
447
+ self._report(body)
448
+ else:
449
+ self._fail(404, "not found")
450
+ except edit.EditError as e:
451
+ self._fail(e.status, e.message)
452
+ except json.JSONDecodeError:
453
+ self._fail(400, "body is not valid JSON")
454
+ except Exception as e:
455
+ _http.exception("%s %s failed", method, sub)
456
+ self._fail(500, f"{type(e).__name__}: {e}")
457
+
458
+ def _log_lineage(self, result: dict[str, Any], started: float) -> None:
459
+ ms = (time.perf_counter() - started) * 1000
460
+ target = result.get("target", {})
461
+ places = sum(len(layer.get("rows", [])) for layer in result.get("layers", []))
462
+ loc = target.get("loc") or target.get("file", "")
463
+ _log.log(
464
+ 30 if ms > SLOW_LINEAGE_MS else 10,
465
+ "%s %s in %.0f ms (%d places)",
466
+ "slow explanation of" if ms > SLOW_LINEAGE_MS else "explained",
467
+ loc,
468
+ ms,
469
+ places,
470
+ extra=logs.ctx(loc=loc, ms=round(ms, 1), places=places, known=target.get("known")),
471
+ )
472
+
473
+ def _client_log(self, body: Any) -> None:
474
+ """Errors the browser reports: the UI, the in-app overlay, the Vite plugin. Size-capped and rate-limited."""
475
+ if not isinstance(body, dict):
476
+ raise edit.EditError(400, "send a JSON object")
477
+ source = str(body.get("source", ""))
478
+ level = str(body.get("level", "error"))
479
+ message = str(body.get("message", "")).strip()
480
+ if source not in CLIENT_SOURCES or level not in CLIENT_LEVELS or not message:
481
+ raise edit.EditError(400, f"need source ({', '.join(CLIENT_SOURCES)}), level and message")
482
+ allowed, dropped = app.limiter.allow(source)
483
+ if dropped:
484
+ _client.warning("dropped %d messages from the %s because it sent too many", dropped, source)
485
+ if allowed:
486
+ fields: dict[str, Any] = {"source": source}
487
+ for key, limit in (("url", 500), ("stack", 8000), ("where", 300)):
488
+ if body.get(key):
489
+ fields[key] = str(body[key])[:limit]
490
+ detail = body.get("detail")
491
+ if isinstance(detail, dict):
492
+ text = json.dumps(detail, default=str)
493
+ fields["detail"] = detail if len(text) <= 2000 else text[:2000]
494
+ _client.log(CLIENT_LEVELS[level], "[%s] %s", source, message[:2000], extra=logs.ctx(**fields))
495
+ _json(self, {"ok": True, "logged": allowed})
496
+
497
+ def _report(self, body: Any) -> None:
498
+ note = str(body.get("note", "")) if isinstance(body, dict) else ""
499
+ data, names = report.build(app.project.config, app.project.snapshot, note, app.project.activity)
500
+ path = report.save(app.project.config, data)
501
+ _log.info("made a problem report: %s", path.name, extra=logs.ctx(path=str(path), size=len(data)))
502
+ self.send_response(200)
503
+ self.send_header("Content-Type", "application/zip")
504
+ self.send_header("Content-Length", str(len(data)))
505
+ self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
506
+ self.send_header("X-Report-Path", str(path.relative_to(app.project.config.root)).replace("\\", "/"))
507
+ self.send_header("X-Report-Files", ",".join(names))
508
+ self.send_header("Cache-Control", "no-store")
509
+ self.end_headers()
510
+ self.wfile.write(data)
511
+
512
+ def _api_get(self, name: str, query: dict[str, list[str]]) -> None:
513
+ q = {k: v[0] for k, v in query.items()}
514
+ if name == "meta":
515
+ _json(self, app.meta())
516
+ elif name == "graph":
517
+ etag = f'"{app.snapshot.rev}"'
518
+ if self.headers.get("If-None-Match") == etag:
519
+ self.send_response(304)
520
+ self.send_header("ETag", etag)
521
+ self.send_header("Content-Length", "0")
522
+ self.end_headers()
523
+ return
524
+ body = json.dumps(
525
+ {"rev": app.snapshot.rev, **app.snapshot.graph.to_json()}, separators=(",", ":")
526
+ ).encode()
527
+ self.send_response(200)
528
+ self.send_header("Content-Type", "application/json; charset=utf-8")
529
+ self.send_header("Content-Length", str(len(body)))
530
+ self.send_header("ETag", etag)
531
+ self.send_header("Cache-Control", "no-cache")
532
+ self.end_headers()
533
+ self.wfile.write(body)
534
+ elif name == "node":
535
+ _json(self, app.node(q.get("id", "")))
536
+ elif name == "source":
537
+ text, digest = edit.read(app.project.config, q.get("file", ""))
538
+ _json(self, {"file": q.get("file"), "text": text, "hash": digest})
539
+ elif name == "explain":
540
+ started = time.perf_counter()
541
+ result = lineage.for_location(app.project, app.snapshot, q.get("loc", ""))
542
+ self._log_lineage(result, started)
543
+ _json(self, result)
544
+ elif name == "logs":
545
+ limit = min(int(q.get("n", "100") or 100), 1000)
546
+ _json(
547
+ self,
548
+ logs.recent(app.project.config.state_dir, q.get("level", "info"), limit, q.get("area") or None),
549
+ )
550
+ elif name == "pages":
551
+ _json(self, app.pages())
552
+ elif name == "search":
553
+ _json(self, app.search(q.get("q", ""), min(int(q.get("limit", "30") or 30), 200)))
554
+ elif name == "findings":
555
+ _json(self, app.findings())
556
+ elif name == "events":
557
+ self._events()
558
+ else:
559
+ self._fail(404, "not found")
560
+
561
+ def _events(self) -> None:
562
+ self.send_response(200)
563
+ self.send_header("Content-Type", "text/event-stream")
564
+ self.send_header("Cache-Control", "no-store")
565
+ self.send_header("Connection", "keep-alive")
566
+ self.end_headers()
567
+ q = app.events.connect()
568
+ _log.debug("a browser window connected for live updates")
569
+ try:
570
+ snap = app.project.snapshot
571
+ hello = {"rev": snap.rev if snap else 0, "activity": app.project.activity}
572
+ self.wfile.write(f"event: hello\ndata: {json.dumps(hello, default=str)}\n\n".encode())
573
+ self.wfile.flush()
574
+ while True:
575
+ try:
576
+ message = q.get(timeout=15)
577
+ except queue.Empty:
578
+ message = ": ping\n\n"
579
+ self.wfile.write(message.encode())
580
+ self.wfile.flush()
581
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
582
+ pass
583
+ finally:
584
+ app.events.disconnect(q)
585
+ _log.debug("a browser window disconnected")
586
+ self.close_connection = True
587
+
588
+ def _static(self, sub: str) -> None:
589
+ name = sub.strip("/") or "index.html"
590
+ if "/" in name or "." not in name: # client-side routes all serve the app shell
591
+ name = "index.html"
592
+ path = (STATIC / name).resolve()
593
+ if not path.is_relative_to(STATIC.resolve()):
594
+ self._fail(404, "not found")
595
+ return
596
+ if not path.is_file() and name != "index.html":
597
+ self._fail(404, "not found")
598
+ return
599
+ if not path.is_file():
600
+ body = _missing_ui().encode()
601
+ ctype = "text/html; charset=utf-8"
602
+ else:
603
+ body = path.read_bytes()
604
+ ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
605
+ if path.name == "index.html":
606
+ boot = json.dumps({"token": app.token, "base": PREFIX, "version": __version__})
607
+ body = body.replace(
608
+ b"<!--stackthread-boot-->",
609
+ f'<script id="stackthread-boot" type="application/json">{boot}</script>'.encode(),
610
+ )
611
+ ctype = "text/html; charset=utf-8"
612
+ self.send_response(200)
613
+ self.send_header("Content-Type", ctype)
614
+ self.send_header("Content-Length", str(len(body)))
615
+ self.send_header("Cache-Control", "no-cache" if path.name == "index.html" else "max-age=60")
616
+ if path.name == "index.html" or not path.is_file():
617
+ self.send_header(
618
+ "Content-Security-Policy",
619
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
620
+ "img-src 'self' data:; connect-src 'self'; frame-src 'self' http://localhost:* "
621
+ "http://127.0.0.1:*; frame-ancestors 'self' http://localhost:* http://127.0.0.1:*",
622
+ )
623
+ self.end_headers()
624
+ self.wfile.write(body)
625
+
626
+ return Handler
627
+
628
+
629
+ def _missing_ui() -> str:
630
+ return (
631
+ "<!doctype html><meta charset=utf-8><title>stackthread</title><body style='font:15px system-ui;padding:2rem'>"
632
+ "<h1>stackthread is running</h1><p>The browser UI is not built in this checkout. Run <code>npm run build</code>"
633
+ " in <code>js/</code>, or install stackthread from PyPI. The API is available under "
634
+ "<code>/__stackthread/api/</code>.</p>"
635
+ )
636
+
637
+
638
+ class Server(ThreadingHTTPServer):
639
+ daemon_threads = True
640
+ allow_reuse_address = False
641
+
642
+
643
+ def bind(port: int, attempts: int = 10) -> tuple[Server, int]:
644
+ """Bind to 127.0.0.1 on the first free port from `port`. Never binds other interfaces."""
645
+ last: OSError | None = None
646
+ for candidate in range(port, port + attempts):
647
+ try:
648
+ return Server(("127.0.0.1", candidate), BaseHTTPRequestHandler), candidate
649
+ except OSError as e:
650
+ last = e
651
+ raise OSError(f"no free port from {port} to {port + attempts - 1}: {last}")
652
+
653
+
654
+ def _first_index(project: Project, poller: Poller | None) -> None:
655
+ """The first full read of the code, while the server already answers. Then start watching for changes."""
656
+ try:
657
+ project.scan()
658
+ except Exception as e:
659
+ _log.exception("the first indexing run failed")
660
+ project._set_activity("failed", error=f"{type(e).__name__}: {e}")
661
+ if poller is not None:
662
+ poller.start()
663
+
664
+
665
+ def serve(project: Project, port: int | None = None, open_browser: bool = True, watch: bool = True) -> None:
666
+ config = project.config
667
+ server, actual = bind(port or config.server.port)
668
+ token = security.new_token()
669
+ app = App(project, actual, token)
670
+ server.RequestHandlerClass = make_handler(app)
671
+ session = security.write_session(config.state_dir, actual, token, config.root)
672
+ url = f"http://127.0.0.1:{actual}{PREFIX}/"
673
+ poller = Poller(project) if watch else None
674
+ _log.info(
675
+ "stackthread %s serving %s at %s",
676
+ __version__,
677
+ config.name,
678
+ url,
679
+ extra=logs.ctx(port=actual, edit=config.server.edit, watch=watch, session=str(session)),
680
+ )
681
+ _log.info("with the Vite plugin, open http://localhost:<vite port>/__stackthread/ for the live inspector")
682
+ _log.info("logs are kept in %s", logs.log_dir(config.state_dir))
683
+ threading.Thread(target=_first_index, args=(project, poller), name="stackthread-index", daemon=True).start()
684
+ if open_browser and config.server.open_browser:
685
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
686
+ try:
687
+ server.serve_forever(poll_interval=0.5)
688
+ except KeyboardInterrupt:
689
+ _log.info("stopping")
690
+ finally:
691
+ if poller is not None:
692
+ poller.stop()
693
+ server.server_close()
694
+ security.clear_session(config.state_dir, token)
695
+ project.close()
696
+ _log.info("stopped")