cosmonapse 0.1.0__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.
- cosmo/__init__.py +0 -0
- cosmo/_install.py +225 -0
- cosmo/commands/__init__.py +0 -0
- cosmo/commands/_prism.py +310 -0
- cosmo/commands/_prism_hero.py +249 -0
- cosmo/commands/_prism_view.py +550 -0
- cosmo/commands/_shared.py +112 -0
- cosmo/commands/completion.py +79 -0
- cosmo/commands/doppler.py +343 -0
- cosmo/commands/init.py +270 -0
- cosmo/commands/prism_dist/assets/index.css +1 -0
- cosmo/commands/prism_dist/assets/mark.png +0 -0
- cosmo/commands/prism_dist/assets/prism.js +40 -0
- cosmo/commands/prism_dist/index.html +14 -0
- cosmo/commands/prism_dist/mark.png +0 -0
- cosmo/commands/synapse.py +690 -0
- cosmo/commands/validate.py +278 -0
- cosmo/main.py +36 -0
- cosmonapse/__init__.py +242 -0
- cosmonapse/_hooks.py +233 -0
- cosmonapse/_neuron_base.py +82 -0
- cosmonapse/_neuron_http.py +184 -0
- cosmonapse/_neuron_mcp.py +365 -0
- cosmonapse/_url.py +72 -0
- cosmonapse/axon.py +297 -0
- cosmonapse/dendrite.py +1976 -0
- cosmonapse/engram/__init__.py +57 -0
- cosmonapse/engram/base.py +231 -0
- cosmonapse/engram/client.py +373 -0
- cosmonapse/engram/memory.py +377 -0
- cosmonapse/engram/postgres.py +423 -0
- cosmonapse/engram/sqlite.py +457 -0
- cosmonapse/envelope.py +1287 -0
- cosmonapse/neuron.py +372 -0
- cosmonapse/pathway.py +385 -0
- cosmonapse/py.typed +0 -0
- cosmonapse/storage/__init__.py +33 -0
- cosmonapse/storage/base.py +113 -0
- cosmonapse/storage/memory.py +73 -0
- cosmonapse/storage/postgres.py +214 -0
- cosmonapse/storage/sqlite.py +233 -0
- cosmonapse/synapse/__init__.py +32 -0
- cosmonapse/synapse/base.py +96 -0
- cosmonapse/synapse/dev.py +560 -0
- cosmonapse/synapse/kafka.py +288 -0
- cosmonapse/synapse/memory.py +207 -0
- cosmonapse/synapse/nats.py +161 -0
- cosmonapse-0.1.0.dist-info/METADATA +232 -0
- cosmonapse-0.1.0.dist-info/RECORD +51 -0
- cosmonapse-0.1.0.dist-info/WHEEL +4 -0
- cosmonapse-0.1.0.dist-info/entry_points.txt +3 -0
cosmo/__init__.py
ADDED
|
File without changes
|
cosmo/_install.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cosmo._install
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
Helpers that make `pip install -e <cosmonapse>` feel like a "real" install
|
|
6
|
+
on a fresh machine by also putting the Python Scripts/bin directory on the
|
|
7
|
+
user's persistent PATH so the `cosmo` command is callable from any shell.
|
|
8
|
+
|
|
9
|
+
This lives in the ``cosmo`` CLI package — not in the ``cosmonapse`` SDK —
|
|
10
|
+
because manipulating the user's shell configuration is CLI/installer
|
|
11
|
+
behaviour, not something an imported library should ever do.
|
|
12
|
+
|
|
13
|
+
Two entry points are exposed by pyproject.toml:
|
|
14
|
+
|
|
15
|
+
* ``cosmonapse-init-path`` — a console script created by pip at install time.
|
|
16
|
+
* ``python -m cosmo._install`` — works even before PATH has been updated.
|
|
17
|
+
|
|
18
|
+
The same module is also invoked automatically by the top-level installer
|
|
19
|
+
script (``cosmonapse-core/install.py``) right after it runs
|
|
20
|
+
``pip install -e``.
|
|
21
|
+
|
|
22
|
+
Public surface:
|
|
23
|
+
|
|
24
|
+
update_path() -> bool # add scripts dir to persistent PATH
|
|
25
|
+
scripts_dir() -> pathlib.Path
|
|
26
|
+
main(argv=None) -> int # CLI entrypoint
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import sysconfig
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Iterable
|
|
36
|
+
|
|
37
|
+
__all__ = ["update_path", "scripts_dir", "main"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Discovery
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
def scripts_dir() -> Path:
|
|
44
|
+
"""Return the directory pip uses for console scripts in the active env.
|
|
45
|
+
|
|
46
|
+
This is the directory that contains ``cosmo`` (or ``cosmo.exe`` on
|
|
47
|
+
Windows) after ``pip install -e .`` has run.
|
|
48
|
+
"""
|
|
49
|
+
# sysconfig knows the canonical location for the running interpreter,
|
|
50
|
+
# including the venv it's executing from.
|
|
51
|
+
paths = sysconfig.get_paths()
|
|
52
|
+
# 'scripts' is what gets used for console_scripts; fall back to
|
|
53
|
+
# the legacy distutils name on truly ancient pythons.
|
|
54
|
+
scripts = paths.get("scripts") or paths.get("Scripts")
|
|
55
|
+
if not scripts:
|
|
56
|
+
# Very unlikely fallback.
|
|
57
|
+
scripts = str(Path(sys.executable).parent)
|
|
58
|
+
return Path(scripts).resolve()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Windows
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
def _update_path_windows(target: Path) -> bool:
|
|
65
|
+
"""Append *target* to HKCU\\Environment\\Path and broadcast the change."""
|
|
66
|
+
import winreg # type: ignore[import-not-found]
|
|
67
|
+
import ctypes
|
|
68
|
+
from ctypes import wintypes
|
|
69
|
+
|
|
70
|
+
key = winreg.OpenKey(
|
|
71
|
+
winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
try:
|
|
75
|
+
current, kind = winreg.QueryValueEx(key, "Path")
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
current, kind = "", winreg.REG_EXPAND_SZ
|
|
78
|
+
|
|
79
|
+
target_str = str(target)
|
|
80
|
+
entries = [e for e in current.split(os.pathsep) if e]
|
|
81
|
+
if any(os.path.normcase(e.rstrip("\\")) == os.path.normcase(target_str.rstrip("\\")) for e in entries):
|
|
82
|
+
print(f"[cosmonapse] PATH already contains {target_str}")
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
new_value = os.pathsep.join(entries + [target_str])
|
|
86
|
+
# REG_EXPAND_SZ preserves things like %USERPROFILE% in the user's PATH.
|
|
87
|
+
winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_value)
|
|
88
|
+
finally:
|
|
89
|
+
winreg.CloseKey(key)
|
|
90
|
+
|
|
91
|
+
# Broadcast WM_SETTINGCHANGE so newly-spawned processes pick up the change.
|
|
92
|
+
HWND_BROADCAST = 0xFFFF
|
|
93
|
+
WM_SETTINGCHANGE = 0x001A
|
|
94
|
+
SMTO_ABORTIFHUNG = 0x0002
|
|
95
|
+
result = wintypes.LPARAM()
|
|
96
|
+
ctypes.windll.user32.SendMessageTimeoutW(
|
|
97
|
+
HWND_BROADCAST,
|
|
98
|
+
WM_SETTINGCHANGE,
|
|
99
|
+
0,
|
|
100
|
+
"Environment",
|
|
101
|
+
SMTO_ABORTIFHUNG,
|
|
102
|
+
5000,
|
|
103
|
+
ctypes.byref(result),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
print(f"[cosmonapse] Added to user PATH: {target_str}")
|
|
107
|
+
print("[cosmonapse] Open a *new* terminal (or sign out / in) for the change to take effect.")
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# macOS / Linux
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
def _shell_rc_candidates() -> Iterable[Path]:
|
|
115
|
+
home = Path.home()
|
|
116
|
+
# Order matters: zsh first on macOS, bash on linux, then fallbacks.
|
|
117
|
+
seen: set[Path] = set()
|
|
118
|
+
for name in (".zshrc", ".bashrc", ".bash_profile", ".profile"):
|
|
119
|
+
p = home / name
|
|
120
|
+
if p not in seen:
|
|
121
|
+
seen.add(p)
|
|
122
|
+
yield p
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _update_path_posix(target: Path) -> bool:
|
|
126
|
+
target_str = str(target)
|
|
127
|
+
marker = "# >>> cosmonapse PATH >>>"
|
|
128
|
+
end_marker = "# <<< cosmonapse PATH <<<"
|
|
129
|
+
block = (
|
|
130
|
+
f"\n{marker}\n"
|
|
131
|
+
f'export PATH="{target_str}:$PATH"\n'
|
|
132
|
+
f"{end_marker}\n"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
updated_any = False
|
|
136
|
+
for rc in _shell_rc_candidates():
|
|
137
|
+
if not rc.exists():
|
|
138
|
+
# Only create .zshrc / .bashrc if the shell that owns them is in use;
|
|
139
|
+
# otherwise we'd be littering the home directory.
|
|
140
|
+
if rc.name not in {".zshrc", ".bashrc"}:
|
|
141
|
+
continue
|
|
142
|
+
shell = os.environ.get("SHELL", "")
|
|
143
|
+
if rc.name == ".zshrc" and not shell.endswith("zsh"):
|
|
144
|
+
continue
|
|
145
|
+
if rc.name == ".bashrc" and not shell.endswith("bash"):
|
|
146
|
+
continue
|
|
147
|
+
rc.touch()
|
|
148
|
+
|
|
149
|
+
content = rc.read_text(encoding="utf-8", errors="replace") if rc.exists() else ""
|
|
150
|
+
if marker in content:
|
|
151
|
+
# Already managed — refresh the block in case the target changed.
|
|
152
|
+
before, _, rest = content.partition(marker)
|
|
153
|
+
_, _, after = rest.partition(end_marker)
|
|
154
|
+
new_content = before.rstrip() + block + after.lstrip()
|
|
155
|
+
if new_content != content:
|
|
156
|
+
rc.write_text(new_content, encoding="utf-8")
|
|
157
|
+
print(f"[cosmonapse] Refreshed cosmonapse PATH block in {rc}")
|
|
158
|
+
updated_any = True
|
|
159
|
+
else:
|
|
160
|
+
print(f"[cosmonapse] {rc} already up to date")
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
# Skip if the user already has this dir on PATH some other way.
|
|
164
|
+
if f'PATH="{target_str}:' in content or f"PATH={target_str}:" in content:
|
|
165
|
+
print(f"[cosmonapse] {rc} already references {target_str}")
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
rc.write_text(content.rstrip() + block, encoding="utf-8")
|
|
169
|
+
print(f"[cosmonapse] Added cosmonapse PATH block to {rc}")
|
|
170
|
+
updated_any = True
|
|
171
|
+
|
|
172
|
+
if updated_any:
|
|
173
|
+
print(
|
|
174
|
+
"[cosmonapse] Open a new terminal (or `source` your shell rc) "
|
|
175
|
+
"for the change to take effect."
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
print("[cosmonapse] No shell rc file needed updating.")
|
|
179
|
+
return updated_any
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Public API
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
def update_path(target: Path | None = None) -> bool:
|
|
186
|
+
"""Add the active env's scripts directory to the user's persistent PATH.
|
|
187
|
+
|
|
188
|
+
Returns True if any change was made.
|
|
189
|
+
"""
|
|
190
|
+
target = (target or scripts_dir()).resolve()
|
|
191
|
+
if not target.exists():
|
|
192
|
+
print(f"[cosmonapse] Scripts directory does not exist yet: {target}", file=sys.stderr)
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
if os.name == "nt":
|
|
196
|
+
return _update_path_windows(target)
|
|
197
|
+
return _update_path_posix(target)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main(argv: list[str] | None = None) -> int:
|
|
201
|
+
parser = argparse.ArgumentParser(
|
|
202
|
+
prog="cosmonapse-init-path",
|
|
203
|
+
description=(
|
|
204
|
+
"Add the active Python environment's scripts directory to your "
|
|
205
|
+
"persistent PATH so the `cosmo` command is callable from any shell."
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--print-only",
|
|
210
|
+
action="store_true",
|
|
211
|
+
help="Show the scripts directory without modifying PATH.",
|
|
212
|
+
)
|
|
213
|
+
args = parser.parse_args(argv)
|
|
214
|
+
|
|
215
|
+
target = scripts_dir()
|
|
216
|
+
print(f"[cosmonapse] Active scripts dir: {target}")
|
|
217
|
+
if args.print_only:
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
changed = update_path(target)
|
|
221
|
+
return 0 if changed or target in [Path(p) for p in os.environ.get("PATH", "").split(os.pathsep)] else 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__": # pragma: no cover
|
|
225
|
+
raise SystemExit(main())
|
|
File without changes
|
cosmo/commands/_prism.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cosmo.commands._prism
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Prism — the browser visualization for the Doppler.
|
|
5
|
+
|
|
6
|
+
Architecture
|
|
7
|
+
------------
|
|
8
|
+
An aiohttp app on a single port (default 7071) serves the Prism single-page
|
|
9
|
+
app and a WebSocket bridge:
|
|
10
|
+
|
|
11
|
+
GET / -> the Prism SPA (index.html)
|
|
12
|
+
GET /view -> back-compat redirect to /?<query> (old two-page flow)
|
|
13
|
+
GET /assets/* -> the SPA's static JS/CSS bundle
|
|
14
|
+
WS /ws -> per-connection Synapse subscriber; broadcasts every
|
|
15
|
+
Signal on the wildcard subject as one JSON line
|
|
16
|
+
|
|
17
|
+
Every WebSocket connection opens its own Synapse client so the user can switch
|
|
18
|
+
URLs/namespaces from the SPA's form without restarting the server. The client
|
|
19
|
+
is closed on WS disconnect.
|
|
20
|
+
|
|
21
|
+
The frontend is a Vite + React + TypeScript app that lives in
|
|
22
|
+
``packages/prism-ui`` and is built to static assets bundled into this wheel at
|
|
23
|
+
``cosmo/commands/prism_dist`` (see that package's README). This module no longer
|
|
24
|
+
templates HTML — it serves the prebuilt SPA and the ``/ws`` bridge. The bridge
|
|
25
|
+
streams one JSON Signal envelope per message; that WS contract is the entire API
|
|
26
|
+
between this server and the SPA.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import json
|
|
33
|
+
import signal as _signal
|
|
34
|
+
import webbrowser
|
|
35
|
+
from importlib.resources import files as _pkg_files
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import click
|
|
39
|
+
|
|
40
|
+
from cosmonapse import Signal, SignalType, discover_signal
|
|
41
|
+
|
|
42
|
+
from cosmo.commands._shared import _HAS_RICH
|
|
43
|
+
|
|
44
|
+
if _HAS_RICH:
|
|
45
|
+
from rich.console import Console
|
|
46
|
+
|
|
47
|
+
console = Console()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Bundled SPA build location
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _prism_dist_dir() -> Path | None:
|
|
55
|
+
"""Locate the bundled Prism SPA build, or None if it was never built.
|
|
56
|
+
|
|
57
|
+
The static assets are produced by ``npm run build:into-wheel`` in
|
|
58
|
+
packages/prism-ui and shipped inside this package as ``prism_dist/``.
|
|
59
|
+
Released wheels always contain it; a source checkout only has it after the
|
|
60
|
+
UI has been built.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
root = Path(str(_pkg_files("cosmo.commands"))) / "prism_dist"
|
|
64
|
+
except (ModuleNotFoundError, TypeError):
|
|
65
|
+
return None
|
|
66
|
+
return root if (root / "index.html").is_file() else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_MISSING_BUILD_HTML = """<!DOCTYPE html>
|
|
70
|
+
<html><head><meta charset="utf-8"><title>Prism - not built</title>
|
|
71
|
+
<style>body{background:#07080c;color:#e6e7ec;font-family:ui-monospace,Menlo,monospace;
|
|
72
|
+
display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
73
|
+
.box{max-width:560px;padding:32px;border:1px solid rgba(255,255,255,.12);border-radius:14px}
|
|
74
|
+
code{color:#22d3ee} h1{font-size:18px;margin:0 0 12px}</style></head>
|
|
75
|
+
<body><div class="box">
|
|
76
|
+
<h1>Prism UI is not bundled in this install</h1>
|
|
77
|
+
<p>The static frontend was not found at <code>cosmo/commands/prism_dist</code>.</p>
|
|
78
|
+
<p>Build it from the repo with:</p>
|
|
79
|
+
<p><code>cd packages/prism-ui && npm install && npm run build:into-wheel</code></p>
|
|
80
|
+
<p>then reinstall the SDK (<code>pip install -e .</code>). Released wheels ship
|
|
81
|
+
the prebuilt UI, so this only appears for source checkouts that have not built
|
|
82
|
+
the UI yet.</p>
|
|
83
|
+
</div></body></html>"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Synapse factory (mirrors the one in doppler.py — kept here to avoid an
|
|
88
|
+
# import cycle and so this module is fully self-contained).
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _make_synapse(base_url: str):
|
|
92
|
+
scheme = base_url.split("://")[0].lower()
|
|
93
|
+
if scheme == "cosmo":
|
|
94
|
+
from cosmonapse.synapse.dev import DevSynapse
|
|
95
|
+
return DevSynapse(url=base_url)
|
|
96
|
+
elif scheme == "nats":
|
|
97
|
+
from cosmonapse.synapse.nats import NatsSynapse
|
|
98
|
+
return NatsSynapse(url=base_url)
|
|
99
|
+
elif scheme == "kafka":
|
|
100
|
+
from cosmonapse.synapse.kafka import KafkaSynapse
|
|
101
|
+
broker = base_url.replace("kafka://", "")
|
|
102
|
+
return KafkaSynapse(bootstrap_servers=broker)
|
|
103
|
+
else:
|
|
104
|
+
raise click.ClickException(
|
|
105
|
+
f"Unknown synapse scheme {scheme!r}. "
|
|
106
|
+
"Use cosmo://, nats://, or kafka://."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _error_envelope(code: str, message: str) -> str:
|
|
111
|
+
"""Synthesize a JSON Signal envelope describing a Prism-side error."""
|
|
112
|
+
return json.dumps({
|
|
113
|
+
"v": "1",
|
|
114
|
+
"id": f"evt_prism_{code}",
|
|
115
|
+
"trace_id": "trc_prism_internal",
|
|
116
|
+
"type": "ERROR",
|
|
117
|
+
"neuron": None,
|
|
118
|
+
"ts": "1970-01-01T00:00:00Z",
|
|
119
|
+
"payload": {"code": code, "message": message, "recoverable": False},
|
|
120
|
+
"meta": {"source": "prism"},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Public entry point
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def run_prism(
|
|
129
|
+
initial_base_url: str | None,
|
|
130
|
+
initial_namespace: str | None,
|
|
131
|
+
port: int,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Start the aiohttp server that hosts Prism (SPA + WS bridge)."""
|
|
134
|
+
try:
|
|
135
|
+
from aiohttp import web
|
|
136
|
+
except ImportError:
|
|
137
|
+
click.echo(
|
|
138
|
+
" aiohttp is required for --prism mode.\n"
|
|
139
|
+
" Install it with: pip install aiohttp\n",
|
|
140
|
+
err=True,
|
|
141
|
+
)
|
|
142
|
+
raise SystemExit(1)
|
|
143
|
+
|
|
144
|
+
dist = _prism_dist_dir()
|
|
145
|
+
|
|
146
|
+
# If the CLI was given a synapse URL, send the browser straight to the
|
|
147
|
+
# visualization by pre-seeding the query string the SPA reads on load.
|
|
148
|
+
initial_qs = ""
|
|
149
|
+
if initial_base_url:
|
|
150
|
+
from urllib.parse import urlencode
|
|
151
|
+
initial_qs = "?" + urlencode({
|
|
152
|
+
"url": initial_base_url,
|
|
153
|
+
"namespace": initial_namespace or "dev",
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
async def handle_index(request):
|
|
157
|
+
if dist is None:
|
|
158
|
+
return web.Response(text=_MISSING_BUILD_HTML, content_type="text/html")
|
|
159
|
+
# Honour a CLI-seeded target on the bare path only (no query yet) so a
|
|
160
|
+
# reload or manual edit of the query string still works.
|
|
161
|
+
if initial_qs and not request.query_string:
|
|
162
|
+
raise web.HTTPFound("/" + initial_qs)
|
|
163
|
+
return web.FileResponse(dist / "index.html")
|
|
164
|
+
|
|
165
|
+
async def handle_view(request):
|
|
166
|
+
# Back-compat with the old two-page flow (/view?url=&namespace=): the
|
|
167
|
+
# SPA now lives at the root and reads the same query params, so just
|
|
168
|
+
# redirect preserving the query string.
|
|
169
|
+
qs = request.query_string
|
|
170
|
+
raise web.HTTPFound("/" + (("?" + qs) if qs else ""))
|
|
171
|
+
|
|
172
|
+
async def handle_ws(request):
|
|
173
|
+
"""
|
|
174
|
+
Per-connection synapse subscriber.
|
|
175
|
+
|
|
176
|
+
Reads ?url=...&namespace=... from the query string, opens its own
|
|
177
|
+
Synapse client, broadcasts a DISCOVER, and forwards every Signal
|
|
178
|
+
on the wildcard subject to the WebSocket as JSON. Tears down both
|
|
179
|
+
the subscription and the synapse when the client disconnects.
|
|
180
|
+
"""
|
|
181
|
+
ws = web.WebSocketResponse(heartbeat=30)
|
|
182
|
+
await ws.prepare(request)
|
|
183
|
+
|
|
184
|
+
base_url = request.query.get("url")
|
|
185
|
+
namespace = request.query.get("namespace") or "dev"
|
|
186
|
+
if not base_url:
|
|
187
|
+
await ws.send_str(_error_envelope("no_url", "missing url query param"))
|
|
188
|
+
await ws.close()
|
|
189
|
+
return ws
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
syn = _make_synapse(base_url)
|
|
193
|
+
except click.ClickException as e:
|
|
194
|
+
await ws.send_str(_error_envelope("bad_url", e.format_message()))
|
|
195
|
+
await ws.close()
|
|
196
|
+
return ws
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
await syn.connect()
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
await ws.send_str(_error_envelope("connect_failed", str(exc)))
|
|
202
|
+
await ws.close()
|
|
203
|
+
return ws
|
|
204
|
+
|
|
205
|
+
async def on_signal(sig: Signal) -> None:
|
|
206
|
+
if ws.closed:
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
await ws.send_str(sig.model_dump_json())
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# Best-effort DISCOVER so existing Dendrites re-publish their
|
|
214
|
+
# REGISTER snapshot and the visualization populates immediately.
|
|
215
|
+
try:
|
|
216
|
+
await syn.publish(
|
|
217
|
+
f"cosmonapse.{namespace}.{SignalType.DISCOVER.value}",
|
|
218
|
+
discover_signal(),
|
|
219
|
+
)
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
await syn.subscribe(
|
|
225
|
+
f"cosmonapse.{namespace}.>",
|
|
226
|
+
on_signal,
|
|
227
|
+
queue_group=None,
|
|
228
|
+
)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
await ws.send_str(_error_envelope("subscribe_failed", str(exc)))
|
|
231
|
+
try:
|
|
232
|
+
await syn.close()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
await ws.close()
|
|
236
|
+
return ws
|
|
237
|
+
|
|
238
|
+
# Keep the connection open until the client disconnects. We don't
|
|
239
|
+
# read meaningful messages; the WS is one-way (server -> browser).
|
|
240
|
+
try:
|
|
241
|
+
async for _ in ws:
|
|
242
|
+
pass
|
|
243
|
+
finally:
|
|
244
|
+
try:
|
|
245
|
+
await syn.close()
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
return ws
|
|
249
|
+
|
|
250
|
+
app = web.Application()
|
|
251
|
+
app.router.add_get("/", handle_index)
|
|
252
|
+
app.router.add_get("/view", handle_view)
|
|
253
|
+
app.router.add_get("/ws", handle_ws)
|
|
254
|
+
if dist is not None:
|
|
255
|
+
app.router.add_static("/assets", str(dist / "assets"))
|
|
256
|
+
|
|
257
|
+
runner = web.AppRunner(app)
|
|
258
|
+
await runner.setup()
|
|
259
|
+
site = web.TCPSite(runner, "127.0.0.1", port)
|
|
260
|
+
await site.start()
|
|
261
|
+
|
|
262
|
+
ui_url = f"http://127.0.0.1:{port}"
|
|
263
|
+
if _HAS_RICH:
|
|
264
|
+
console.print()
|
|
265
|
+
console.print(" [bold cyan]cosmo doppler[/bold cyan] [dim]--prism[/dim]")
|
|
266
|
+
if initial_base_url:
|
|
267
|
+
console.print(
|
|
268
|
+
f" Synapse: [cyan]{initial_base_url}/{initial_namespace or 'dev'}[/cyan]"
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
console.print(" Synapse: [dim](enter URL in the form)[/dim]")
|
|
272
|
+
console.print(f" Prism: [underline cyan]{ui_url}[/underline cyan]")
|
|
273
|
+
console.print()
|
|
274
|
+
console.print(" [dim]Ctrl-C to stop[/dim]")
|
|
275
|
+
console.print(" " + "-" * 60)
|
|
276
|
+
console.print()
|
|
277
|
+
else:
|
|
278
|
+
print("\n cosmo doppler --prism")
|
|
279
|
+
if initial_base_url:
|
|
280
|
+
print(f" Synapse: {initial_base_url}/{initial_namespace or 'dev'}")
|
|
281
|
+
else:
|
|
282
|
+
print(" Synapse: (enter URL in the form)")
|
|
283
|
+
print(f" Prism: {ui_url}")
|
|
284
|
+
print(" Ctrl-C to stop\n")
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
webbrowser.open(ui_url)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
stop = asyncio.Event()
|
|
292
|
+
loop = asyncio.get_event_loop()
|
|
293
|
+
for s in (_signal.SIGINT, _signal.SIGTERM):
|
|
294
|
+
try:
|
|
295
|
+
loop.add_signal_handler(s, stop.set)
|
|
296
|
+
except NotImplementedError:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
await stop.wait()
|
|
301
|
+
except KeyboardInterrupt:
|
|
302
|
+
pass
|
|
303
|
+
finally:
|
|
304
|
+
await runner.cleanup()
|
|
305
|
+
if _HAS_RICH:
|
|
306
|
+
console.print()
|
|
307
|
+
console.print(" [dim]Prism stopped.[/dim]")
|
|
308
|
+
console.print()
|
|
309
|
+
else:
|
|
310
|
+
print("\n Prism stopped.\n")
|