stapel-realtime 0.1.1__tar.gz
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.
- stapel_realtime-0.1.1/CONFIG.MD +56 -0
- stapel_realtime-0.1.1/LICENSE +21 -0
- stapel_realtime-0.1.1/PKG-INFO +215 -0
- stapel_realtime-0.1.1/README.md +175 -0
- stapel_realtime-0.1.1/__init__.py +113 -0
- stapel_realtime-0.1.1/apps.py +26 -0
- stapel_realtime-0.1.1/asgi.py +200 -0
- stapel_realtime-0.1.1/authorize.py +119 -0
- stapel_realtime-0.1.1/checks.py +214 -0
- stapel_realtime-0.1.1/close_codes.py +92 -0
- stapel_realtime-0.1.1/conf.py +66 -0
- stapel_realtime-0.1.1/consumers.py +458 -0
- stapel_realtime-0.1.1/delivery.py +173 -0
- stapel_realtime-0.1.1/docs/capabilities.json +284 -0
- stapel_realtime-0.1.1/docs/llms.txt +89 -0
- stapel_realtime-0.1.1/envelope.py +187 -0
- stapel_realtime-0.1.1/py.typed +0 -0
- stapel_realtime-0.1.1/pyproject.toml +115 -0
- stapel_realtime-0.1.1/schemas/wire/envelope.v1.json +96 -0
- stapel_realtime-0.1.1/setup.cfg +4 -0
- stapel_realtime-0.1.1/stapel_realtime.egg-info/PKG-INFO +215 -0
- stapel_realtime-0.1.1/stapel_realtime.egg-info/SOURCES.txt +52 -0
- stapel_realtime-0.1.1/stapel_realtime.egg-info/dependency_links.txt +1 -0
- stapel_realtime-0.1.1/stapel_realtime.egg-info/requires.txt +18 -0
- stapel_realtime-0.1.1/stapel_realtime.egg-info/top_level.txt +1 -0
- stapel_realtime-0.1.1/streams.py +118 -0
- stapel_realtime-0.1.1/testing.py +142 -0
- stapel_realtime-0.1.1/tests/test_asgi.py +176 -0
- stapel_realtime-0.1.1/tests/test_authorize.py +100 -0
- stapel_realtime-0.1.1/tests/test_checks.py +161 -0
- stapel_realtime-0.1.1/tests/test_consumers_ephemeral.py +314 -0
- stapel_realtime-0.1.1/tests/test_consumers_resumable.py +185 -0
- stapel_realtime-0.1.1/tests/test_contract.py +91 -0
- stapel_realtime-0.1.1/tests/test_delivery.py +146 -0
- stapel_realtime-0.1.1/tests/test_envelope.py +149 -0
- stapel_realtime-0.1.1/tests/test_public_api.py +124 -0
- stapel_realtime-0.1.1/tests/test_streams.py +89 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# CONFIG.MD — stapel-realtime
|
|
2
|
+
|
|
3
|
+
Config registry for **stapel-realtime** (`static-scaffold-and-config.md` §2).
|
|
4
|
+
One row per configuration key the library reads, its **source** (`env` = the
|
|
5
|
+
process environment / the `STAPEL_REALTIME` settings dict), what it is for,
|
|
6
|
+
whether it is required, and its default.
|
|
7
|
+
|
|
8
|
+
stapel-realtime is an **L1 library** — no models, migrations, views, urls or
|
|
9
|
+
comm surface of its own. Unlike most L1 libraries it *is* added to
|
|
10
|
+
`INSTALLED_APPS` on a host that serves WebSockets, because it carries system
|
|
11
|
+
checks and an unregistered check is a comment. A host that only imports the
|
|
12
|
+
envelope or the stream-key helpers does not need the app entry.
|
|
13
|
+
|
|
14
|
+
Every key is read through `realtime_settings`
|
|
15
|
+
(`stapel_realtime.conf.AppSettings`, namespace `STAPEL_REALTIME`).
|
|
16
|
+
Resolution order per key: `settings.STAPEL_REALTIME` dict → a flat Django
|
|
17
|
+
setting of the same name → environment variable → the default below.
|
|
18
|
+
|
|
19
|
+
The spec (`tasks/stapel-realtime-design.md` §4.1) names two of these axes
|
|
20
|
+
`REALTIME_HEARTBEAT_S` and `REALTIME_MAX_REPLAY`; under the fleet's settings
|
|
21
|
+
canon a package's keys are unprefixed inside its own namespace, so they are
|
|
22
|
+
`HEARTBEAT_S` and `MAX_REPLAY` here. Same axes, canonical spelling.
|
|
23
|
+
|
|
24
|
+
## stapel-realtime
|
|
25
|
+
|
|
26
|
+
| Key | Source | Purpose | Required | Default |
|
|
27
|
+
|-----|--------|---------|----------|---------|
|
|
28
|
+
| HEARTBEAT_S | env | seconds between server `ping` frames; each tick also re-checks the JWT `exp` and closes 4401 if it has passed. 0 disables both (realtime.W003). | no | 25 |
|
|
29
|
+
| HEARTBEAT_TIMEOUT_S | env | seconds to wait for the client's `pong` before closing 4408. | no | 10 |
|
|
30
|
+
| MAX_REPLAY | env | widest resume gap replayed inline on a resumable stream, and the `limit` passed to the module's `get_replay_rows` hook; a wider gap answers `error{code=resync}`. | no | 500 |
|
|
31
|
+
| SEND_QUEUE_SIZE | env | outbound frames buffered per socket before the substrate closes 4413 rather than waiting on a slow reader. | no | 100 |
|
|
32
|
+
| AUTHORIZE_CACHE_S | env | seconds an `authorize()` verdict is reused for the same (user, stream) within one socket; the acknowledged ceiling on the residual leak window for non-revoke paths. | no | 30 |
|
|
33
|
+
| ALLOWED_ORIGINS | env | exact origins WITH port that may open a socket; empty disables the guard (realtime.W002), a malformed entry is realtime.E003. | no | [] |
|
|
34
|
+
| URL_PREFIX | env | edge convention socket routes live under (`/ws/<mod>/…`), asserted by realtime.W004. | no | ws |
|
|
35
|
+
| LAYER_SOCKET_TIMEOUT_MIN | env | floor for the redis channel layer's `socket_timeout`; `None` derives it from the layer's `expiry + 10` (realtime.E002). | no | None |
|
|
36
|
+
|
|
37
|
+
## Settings this library reads but does not own
|
|
38
|
+
|
|
39
|
+
| Setting | Owner | Why it matters here |
|
|
40
|
+
|---------|-------|---------------------|
|
|
41
|
+
| `CHANNEL_LAYERS["default"]` | Django Channels | The fan-out backend. Absent → delivery is a silent no-op (realtime.W001). In-memory with more than one worker → realtime.E001. Its `CONFIG.socket_timeout` / `CONFIG.connection_kwargs.socket_timeout` and `CONFIG.expiry` are what realtime.E002 reads. |
|
|
42
|
+
| `WEB_CONCURRENCY` / `UVICORN_WORKERS` / `GUNICORN_WORKERS` | the ASGI server | How realtime.E001 learns the worker count. |
|
|
43
|
+
| the `stapel-core` JWT settings | stapel-core | `build_websocket_application()` installs core's G14 middleware; the socket authenticates with exactly the HTTP token stack. |
|
|
44
|
+
| `STAPEL_COMM["SIGNAL_TRANSPORT"]` | stapel-core | Selects the delivery backend for `comm.signal()`. This library registers itself as `"channels"`; the core's default is `"none"`, which makes every `signal()` a silent no-op. An unresolvable value is reported by `stapel_core.comm.E003`. |
|
|
45
|
+
|
|
46
|
+
## e2e host (not shipped in the wheel)
|
|
47
|
+
|
|
48
|
+
`e2e/` is the two-worker live harness (`make e2e`), not part of the library.
|
|
49
|
+
Its knobs are listed because a config registry that skips the reads it can see
|
|
50
|
+
teaches everyone that the registry is optional.
|
|
51
|
+
|
|
52
|
+
| Key | Source | Purpose | Required | Default |
|
|
53
|
+
|-----|--------|---------|----------|---------|
|
|
54
|
+
| REALTIME_E2E_DIR | env | scratch directory the driver wipes and recreates each run. | no | /tmp/stapel-realtime-e2e |
|
|
55
|
+
| REALTIME_E2E_DB | env | sqlite path both worker processes share. | no | /tmp/stapel-realtime-e2e/db.sqlite3 |
|
|
56
|
+
| REALTIME_E2E_REDIS | env | redis URL for the channel layer both workers fan out through. | no | redis://127.0.0.1:6399/0 |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stapel contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-realtime
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Realtime delivery substrate for the Stapel framework — the Signal primitive on the wire
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/usestapel/stapel-realtime
|
|
7
|
+
Project-URL: Repository, https://github.com/usestapel/stapel-realtime
|
|
8
|
+
Project-URL: Documentation, https://github.com/usestapel/stapel-realtime#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/usestapel/stapel-realtime/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/usestapel/stapel-realtime/issues
|
|
11
|
+
Keywords: django,stapel,realtime,websocket,channels,signal
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: stapel-core<0.34,>=0.33.2
|
|
26
|
+
Provides-Extra: channels
|
|
27
|
+
Requires-Dist: stapel-core[channels]<0.34,>=0.33.2; extra == "channels"
|
|
28
|
+
Requires-Dist: channels<5,>=4.0; extra == "channels"
|
|
29
|
+
Provides-Extra: redis
|
|
30
|
+
Requires-Dist: channels-redis<5,>=4.2; extra == "redis"
|
|
31
|
+
Provides-Extra: testing
|
|
32
|
+
Requires-Dist: channels<5,>=4.0; extra == "testing"
|
|
33
|
+
Requires-Dist: daphne<5,>=4; extra == "testing"
|
|
34
|
+
Provides-Extra: all
|
|
35
|
+
Requires-Dist: stapel-core[channels]<0.34,>=0.33.2; extra == "all"
|
|
36
|
+
Requires-Dist: channels<5,>=4.0; extra == "all"
|
|
37
|
+
Requires-Dist: channels-redis<5,>=4.2; extra == "all"
|
|
38
|
+
Requires-Dist: daphne<5,>=4; extra == "all"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
|
|
42
|
+
|
|
43
|
+
# stapel-realtime
|
|
44
|
+
|
|
45
|
+
[](https://github.com/usestapel/stapel-realtime/actions/workflows/ci.yml?query=branch%3Amain)
|
|
46
|
+
[](https://app.codecov.io/gh/usestapel/stapel-realtime)
|
|
47
|
+
[](https://github.com/usestapel/stapel-realtime)
|
|
48
|
+
[](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE)
|
|
49
|
+
[](https://github.com/usestapel/stapel-realtime/blob/main/docs/llms.txt)
|
|
50
|
+
|
|
51
|
+
> Realtime delivery substrate: the L1 library behind the Signal primitive (stapel_core.comm.signal). Ships the Channels/Redis transport for the core's signal-delivery seam, the two consumers every browser socket in the fleet is built from (EphemeralStreamConsumer for at-most-once Signal fan-out; ResumableStreamConsumer for hello/welcome/replay/live journals with seq dedup and a bounded replay window), the versioned v1 wire envelope, the canonical <mod>:<scope_type>:<scope_id>[:<topic>] stream key, a fail-closed per-stream authorize seam with the workspace-capability authorizer, revoke-to-kick, heartbeat with JWT-exp re-check, disconnect-on-overflow backpressure, the fleet close-code canon, build_websocket_application() host assembly with a port-aware origin guard, and five system checks. No models, migrations, views, urls or comm surface of its own; it is installed as a Django app only so its checks are registered.
|
|
52
|
+
|
|
53
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Not published on PyPI yet. Install from source:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install git+https://github.com/usestapel/stapel-realtime
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## At a glance
|
|
64
|
+
|
|
65
|
+
| Fact | Value |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Version | `0.1.1` |
|
|
68
|
+
| Python | `>=3.11` (3.11, 3.12, 3.13) |
|
|
69
|
+
| Config axes | 8 |
|
|
70
|
+
| Usage surface | 17 |
|
|
71
|
+
| Extension points | 6 |
|
|
72
|
+
| Fleet dependencies | [`stapel-core`](https://github.com/usestapel/stapel-core) |
|
|
73
|
+
|
|
74
|
+
## Documentation
|
|
75
|
+
|
|
76
|
+
[capabilities.json](https://github.com/usestapel/stapel-realtime/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-realtime/blob/main/docs/llms.txt)
|
|
77
|
+
|
|
78
|
+
## What this is
|
|
79
|
+
|
|
80
|
+
**The delivery half of the fourth communication primitive.**
|
|
81
|
+
|
|
82
|
+
Three primitives in `stapel_core.comm` address code. **Function** — "answer me
|
|
83
|
+
now", the caller waits. **Action** — "this happened, the system must know":
|
|
84
|
+
outbox, at-least-once, 0..N module subscribers. **Task** — "do the long work",
|
|
85
|
+
the system waits, not the caller.
|
|
86
|
+
|
|
87
|
+
**Signal** is the fourth, and its addressee is a human looking at a screen:
|
|
88
|
+
|
|
89
|
+
> Show this to whoever is watching right now.
|
|
90
|
+
|
|
91
|
+
There is no obligation to an observer who is not watching. When they look, they
|
|
92
|
+
read current state over REST — the truth is in the database, and the value of a
|
|
93
|
+
signal expires in seconds. **Losing a signal is correct behaviour**, and that
|
|
94
|
+
one property is what lets this library be small: no outbox row, no retry, no
|
|
95
|
+
history, no delivery receipt.
|
|
96
|
+
|
|
97
|
+
`stapel_core.comm.signal()` is the emitter — sixty lines of stdlib, free for
|
|
98
|
+
every library in the fleet, a silent no-op with no backend configured. This
|
|
99
|
+
package is everything on the other side of that call.
|
|
100
|
+
|
|
101
|
+
## What it ships
|
|
102
|
+
|
|
103
|
+
| | |
|
|
104
|
+
|---|---|
|
|
105
|
+
| **Transport** | `deliver(stream_key, frame)` — the callable the core's `STAPEL_COMM["SIGNAL_TRANSPORT"] = "channels"` resolves to, registered from this package's `AppConfig.ready()`; plus `deliver_frame()` for journal fan-out and `revoke()` for the kick. Best-effort by contract: no layer, no subscriber, dead redis → the frame is dropped and nothing raises. |
|
|
106
|
+
| **Two consumers** | `EphemeralStreamConsumer` (Signal fan-out, no `seq`, no history) and `ResumableStreamConsumer` (`hello{last_seq}` → `welcome` → replay → live, deduplicated by `seq`, bounded replay window). Both are generalizations of `stapel_chat.ChatConsumer`, the one protocol the fleet had actually proven. |
|
|
107
|
+
| **Wire envelope v1** | `{v, type, stream, payload, seq?}` — the shape `comm.signal()` builds and this substrate forwards verbatim, published as a JSON schema (the deliberate exception to "an L1 library ships no schemas": the contract is shared by a backend consumer and a browser client written by different hands). Frame kind is structural — `seq` present means journal, absent means ephemeral. |
|
|
108
|
+
| **Stream keys** | `<mod>:<scope_type>:<scope_id>[:<topic>]`, built and validated by the core's `comm.stream_key()` (re-exported here, never re-implemented). The scope is *in the name*, so a group physically cannot cross a workspace. |
|
|
109
|
+
| **Authorization** | A per-stream `authorize()` hook that is **fail-closed**: a consumer that does not implement it subscribes nobody. `WorkspaceCapability` is the canonical implementation — the same `require_capability` predicate HTTP uses. |
|
|
110
|
+
| **Revoke → kick** | `revoke(stream_key, user_id)` sends a `kick` frame and closes 4410 immediately, rather than leaking until the client happens to reconnect. |
|
|
111
|
+
| **Host assembly** | `build_websocket_application()` — origin guard (compared **with the port**) over core's G14 JWT stack over every installed module's routing manifest, discovered rather than listed. |
|
|
112
|
+
| **System checks** | Five, each one a production bruise turned into a `manage.py check` verdict. |
|
|
113
|
+
| **Test harness** | `stapel_realtime.testing.open_stream()` — an envelope-aware Channels client, so a module testing its consumer does not wire the fourth `WebsocketCommunicator` by hand. |
|
|
114
|
+
|
|
115
|
+
## Quick start
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# myapp/consumers.py
|
|
119
|
+
from stapel_realtime import EphemeralStreamConsumer, WorkspaceCapability
|
|
120
|
+
|
|
121
|
+
class RecordingsConsumer(EphemeralStreamConsumer):
|
|
122
|
+
module = "recordings"
|
|
123
|
+
scope_type = "ws"
|
|
124
|
+
stream_key_kwarg = "workspace_id"
|
|
125
|
+
authorizer = WorkspaceCapability("recordings.read")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
# myapp/routing.py — the manifest the host assembly discovers
|
|
130
|
+
from django.urls import path
|
|
131
|
+
from .consumers import RecordingsConsumer
|
|
132
|
+
|
|
133
|
+
websocket_urlpatterns = [
|
|
134
|
+
path("ws/recordings/<uuid:workspace_id>", RecordingsConsumer.as_asgi()),
|
|
135
|
+
]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# myapp/services.py — the emit side. Note what is NOT imported: a module that
|
|
140
|
+
# only signals depends on the core, never on this library.
|
|
141
|
+
from stapel_core.comm import signal, stream_key
|
|
142
|
+
|
|
143
|
+
with transaction.atomic():
|
|
144
|
+
recording.status = "ready"
|
|
145
|
+
recording.save()
|
|
146
|
+
signal(stream_key("recordings", "ws", recording.workspace_id),
|
|
147
|
+
"recording.status",
|
|
148
|
+
{"recording_id": str(recording.pk), "status": recording.status})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# asgi.py — the whole host
|
|
153
|
+
from django.core.asgi import get_asgi_application
|
|
154
|
+
from stapel_realtime.asgi import build_websocket_application
|
|
155
|
+
|
|
156
|
+
application = build_websocket_application(http_application=get_asgi_application())
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# settings.py
|
|
161
|
+
INSTALLED_APPS += ["stapel_realtime"] # so the system checks are registered
|
|
162
|
+
|
|
163
|
+
STAPEL_COMM = {"SIGNAL_TRANSPORT": "channels"} # opt in; the default is "none"
|
|
164
|
+
|
|
165
|
+
STAPEL_REALTIME = {
|
|
166
|
+
"ALLOWED_ORIGINS": ["https://app.example.com"], # WITH the port if non-default
|
|
167
|
+
}
|
|
168
|
+
CHANNEL_LAYERS = {
|
|
169
|
+
"default": {
|
|
170
|
+
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
|
171
|
+
"CONFIG": {"hosts": ["redis://redis:6379/0"]},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Install: `pip install 'stapel-realtime[channels,redis]'` on a host that serves
|
|
177
|
+
sockets, and `[testing]` on top wherever a module tests its own consumer (that
|
|
178
|
+
extra adds daphne, which `channels.testing` drags in — no reason to put an ASGI
|
|
179
|
+
server on a production host). A module that only *emits* needs nothing from
|
|
180
|
+
here at all: `comm.signal()` lives in the core, and that is the point.
|
|
181
|
+
|
|
182
|
+
## The rule that keeps a fifth implementation from appearing
|
|
183
|
+
|
|
184
|
+
Before this library the fleet had **three** independent browser sockets (chat,
|
|
185
|
+
video, studio-dialog) plus a machine peer protocol, each with its own JWT
|
|
186
|
+
handling, its own close codes, and — twice, independently — its own resume
|
|
187
|
+
protocol. The boundary is drawn by *who is on the other end*:
|
|
188
|
+
|
|
189
|
+
> **A human in a browser → `stapel-realtime`.**
|
|
190
|
+
> **One of our own processes → an application-level protocol**
|
|
191
|
+
> (`stapel-runner-protocol`), and it owes an answer to "why not a Function or a
|
|
192
|
+
> Task".
|
|
193
|
+
|
|
194
|
+
The machine protocol stays separate on merit, not inertia: a dropped
|
|
195
|
+
`task.assign` frame is unacceptable where a dropped signal is correct, it needs
|
|
196
|
+
exactly-once apply keyed by `(task_id, seq)`, and it is deliberately
|
|
197
|
+
transport-agnostic so it can be tested without a network.
|
|
198
|
+
|
|
199
|
+
## What it does not do
|
|
200
|
+
|
|
201
|
+
Not in v1, on purpose: a presence registry, an SSE fallback, one multiplexed
|
|
202
|
+
socket for many streams (the envelope reserves `stream` so adding it later is
|
|
203
|
+
not a breaking change), NATS as the signal transport (that is a future value of
|
|
204
|
+
the core's axis, for the microservice topology), client→server commands over
|
|
205
|
+
the socket (writes go through REST/Function), and delivering Actions to the
|
|
206
|
+
browser as-is — an anti-pattern, because a five-minute-late "typing…" retried
|
|
207
|
+
by an outbox is worse than no delivery at all.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT — see [LICENSE](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE).
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
|
|
2
|
+
|
|
3
|
+
# stapel-realtime
|
|
4
|
+
|
|
5
|
+
[](https://github.com/usestapel/stapel-realtime/actions/workflows/ci.yml?query=branch%3Amain)
|
|
6
|
+
[](https://app.codecov.io/gh/usestapel/stapel-realtime)
|
|
7
|
+
[](https://github.com/usestapel/stapel-realtime)
|
|
8
|
+
[](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE)
|
|
9
|
+
[](https://github.com/usestapel/stapel-realtime/blob/main/docs/llms.txt)
|
|
10
|
+
|
|
11
|
+
> Realtime delivery substrate: the L1 library behind the Signal primitive (stapel_core.comm.signal). Ships the Channels/Redis transport for the core's signal-delivery seam, the two consumers every browser socket in the fleet is built from (EphemeralStreamConsumer for at-most-once Signal fan-out; ResumableStreamConsumer for hello/welcome/replay/live journals with seq dedup and a bounded replay window), the versioned v1 wire envelope, the canonical <mod>:<scope_type>:<scope_id>[:<topic>] stream key, a fail-closed per-stream authorize seam with the workspace-capability authorizer, revoke-to-kick, heartbeat with JWT-exp re-check, disconnect-on-overflow backpressure, the fleet close-code canon, build_websocket_application() host assembly with a port-aware origin guard, and five system checks. No models, migrations, views, urls or comm surface of its own; it is installed as a Django app only so its checks are registered.
|
|
12
|
+
|
|
13
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Not published on PyPI yet. Install from source:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install git+https://github.com/usestapel/stapel-realtime
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## At a glance
|
|
24
|
+
|
|
25
|
+
| Fact | Value |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Version | `0.1.1` |
|
|
28
|
+
| Python | `>=3.11` (3.11, 3.12, 3.13) |
|
|
29
|
+
| Config axes | 8 |
|
|
30
|
+
| Usage surface | 17 |
|
|
31
|
+
| Extension points | 6 |
|
|
32
|
+
| Fleet dependencies | [`stapel-core`](https://github.com/usestapel/stapel-core) |
|
|
33
|
+
|
|
34
|
+
## Documentation
|
|
35
|
+
|
|
36
|
+
[capabilities.json](https://github.com/usestapel/stapel-realtime/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-realtime/blob/main/docs/llms.txt)
|
|
37
|
+
|
|
38
|
+
## What this is
|
|
39
|
+
|
|
40
|
+
**The delivery half of the fourth communication primitive.**
|
|
41
|
+
|
|
42
|
+
Three primitives in `stapel_core.comm` address code. **Function** — "answer me
|
|
43
|
+
now", the caller waits. **Action** — "this happened, the system must know":
|
|
44
|
+
outbox, at-least-once, 0..N module subscribers. **Task** — "do the long work",
|
|
45
|
+
the system waits, not the caller.
|
|
46
|
+
|
|
47
|
+
**Signal** is the fourth, and its addressee is a human looking at a screen:
|
|
48
|
+
|
|
49
|
+
> Show this to whoever is watching right now.
|
|
50
|
+
|
|
51
|
+
There is no obligation to an observer who is not watching. When they look, they
|
|
52
|
+
read current state over REST — the truth is in the database, and the value of a
|
|
53
|
+
signal expires in seconds. **Losing a signal is correct behaviour**, and that
|
|
54
|
+
one property is what lets this library be small: no outbox row, no retry, no
|
|
55
|
+
history, no delivery receipt.
|
|
56
|
+
|
|
57
|
+
`stapel_core.comm.signal()` is the emitter — sixty lines of stdlib, free for
|
|
58
|
+
every library in the fleet, a silent no-op with no backend configured. This
|
|
59
|
+
package is everything on the other side of that call.
|
|
60
|
+
|
|
61
|
+
## What it ships
|
|
62
|
+
|
|
63
|
+
| | |
|
|
64
|
+
|---|---|
|
|
65
|
+
| **Transport** | `deliver(stream_key, frame)` — the callable the core's `STAPEL_COMM["SIGNAL_TRANSPORT"] = "channels"` resolves to, registered from this package's `AppConfig.ready()`; plus `deliver_frame()` for journal fan-out and `revoke()` for the kick. Best-effort by contract: no layer, no subscriber, dead redis → the frame is dropped and nothing raises. |
|
|
66
|
+
| **Two consumers** | `EphemeralStreamConsumer` (Signal fan-out, no `seq`, no history) and `ResumableStreamConsumer` (`hello{last_seq}` → `welcome` → replay → live, deduplicated by `seq`, bounded replay window). Both are generalizations of `stapel_chat.ChatConsumer`, the one protocol the fleet had actually proven. |
|
|
67
|
+
| **Wire envelope v1** | `{v, type, stream, payload, seq?}` — the shape `comm.signal()` builds and this substrate forwards verbatim, published as a JSON schema (the deliberate exception to "an L1 library ships no schemas": the contract is shared by a backend consumer and a browser client written by different hands). Frame kind is structural — `seq` present means journal, absent means ephemeral. |
|
|
68
|
+
| **Stream keys** | `<mod>:<scope_type>:<scope_id>[:<topic>]`, built and validated by the core's `comm.stream_key()` (re-exported here, never re-implemented). The scope is *in the name*, so a group physically cannot cross a workspace. |
|
|
69
|
+
| **Authorization** | A per-stream `authorize()` hook that is **fail-closed**: a consumer that does not implement it subscribes nobody. `WorkspaceCapability` is the canonical implementation — the same `require_capability` predicate HTTP uses. |
|
|
70
|
+
| **Revoke → kick** | `revoke(stream_key, user_id)` sends a `kick` frame and closes 4410 immediately, rather than leaking until the client happens to reconnect. |
|
|
71
|
+
| **Host assembly** | `build_websocket_application()` — origin guard (compared **with the port**) over core's G14 JWT stack over every installed module's routing manifest, discovered rather than listed. |
|
|
72
|
+
| **System checks** | Five, each one a production bruise turned into a `manage.py check` verdict. |
|
|
73
|
+
| **Test harness** | `stapel_realtime.testing.open_stream()` — an envelope-aware Channels client, so a module testing its consumer does not wire the fourth `WebsocketCommunicator` by hand. |
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# myapp/consumers.py
|
|
79
|
+
from stapel_realtime import EphemeralStreamConsumer, WorkspaceCapability
|
|
80
|
+
|
|
81
|
+
class RecordingsConsumer(EphemeralStreamConsumer):
|
|
82
|
+
module = "recordings"
|
|
83
|
+
scope_type = "ws"
|
|
84
|
+
stream_key_kwarg = "workspace_id"
|
|
85
|
+
authorizer = WorkspaceCapability("recordings.read")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# myapp/routing.py — the manifest the host assembly discovers
|
|
90
|
+
from django.urls import path
|
|
91
|
+
from .consumers import RecordingsConsumer
|
|
92
|
+
|
|
93
|
+
websocket_urlpatterns = [
|
|
94
|
+
path("ws/recordings/<uuid:workspace_id>", RecordingsConsumer.as_asgi()),
|
|
95
|
+
]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# myapp/services.py — the emit side. Note what is NOT imported: a module that
|
|
100
|
+
# only signals depends on the core, never on this library.
|
|
101
|
+
from stapel_core.comm import signal, stream_key
|
|
102
|
+
|
|
103
|
+
with transaction.atomic():
|
|
104
|
+
recording.status = "ready"
|
|
105
|
+
recording.save()
|
|
106
|
+
signal(stream_key("recordings", "ws", recording.workspace_id),
|
|
107
|
+
"recording.status",
|
|
108
|
+
{"recording_id": str(recording.pk), "status": recording.status})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
# asgi.py — the whole host
|
|
113
|
+
from django.core.asgi import get_asgi_application
|
|
114
|
+
from stapel_realtime.asgi import build_websocket_application
|
|
115
|
+
|
|
116
|
+
application = build_websocket_application(http_application=get_asgi_application())
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# settings.py
|
|
121
|
+
INSTALLED_APPS += ["stapel_realtime"] # so the system checks are registered
|
|
122
|
+
|
|
123
|
+
STAPEL_COMM = {"SIGNAL_TRANSPORT": "channels"} # opt in; the default is "none"
|
|
124
|
+
|
|
125
|
+
STAPEL_REALTIME = {
|
|
126
|
+
"ALLOWED_ORIGINS": ["https://app.example.com"], # WITH the port if non-default
|
|
127
|
+
}
|
|
128
|
+
CHANNEL_LAYERS = {
|
|
129
|
+
"default": {
|
|
130
|
+
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
|
131
|
+
"CONFIG": {"hosts": ["redis://redis:6379/0"]},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Install: `pip install 'stapel-realtime[channels,redis]'` on a host that serves
|
|
137
|
+
sockets, and `[testing]` on top wherever a module tests its own consumer (that
|
|
138
|
+
extra adds daphne, which `channels.testing` drags in — no reason to put an ASGI
|
|
139
|
+
server on a production host). A module that only *emits* needs nothing from
|
|
140
|
+
here at all: `comm.signal()` lives in the core, and that is the point.
|
|
141
|
+
|
|
142
|
+
## The rule that keeps a fifth implementation from appearing
|
|
143
|
+
|
|
144
|
+
Before this library the fleet had **three** independent browser sockets (chat,
|
|
145
|
+
video, studio-dialog) plus a machine peer protocol, each with its own JWT
|
|
146
|
+
handling, its own close codes, and — twice, independently — its own resume
|
|
147
|
+
protocol. The boundary is drawn by *who is on the other end*:
|
|
148
|
+
|
|
149
|
+
> **A human in a browser → `stapel-realtime`.**
|
|
150
|
+
> **One of our own processes → an application-level protocol**
|
|
151
|
+
> (`stapel-runner-protocol`), and it owes an answer to "why not a Function or a
|
|
152
|
+
> Task".
|
|
153
|
+
|
|
154
|
+
The machine protocol stays separate on merit, not inertia: a dropped
|
|
155
|
+
`task.assign` frame is unacceptable where a dropped signal is correct, it needs
|
|
156
|
+
exactly-once apply keyed by `(task_id, seq)`, and it is deliberately
|
|
157
|
+
transport-agnostic so it can be tested without a network.
|
|
158
|
+
|
|
159
|
+
## What it does not do
|
|
160
|
+
|
|
161
|
+
Not in v1, on purpose: a presence registry, an SSE fallback, one multiplexed
|
|
162
|
+
socket for many streams (the envelope reserves `stream` so adding it later is
|
|
163
|
+
not a breaking change), NATS as the signal transport (that is a future value of
|
|
164
|
+
the core's axis, for the microservice topology), client→server commands over
|
|
165
|
+
the socket (writes go through REST/Function), and delivering Actions to the
|
|
166
|
+
browser as-is — an anti-pattern, because a five-minute-late "typing…" retried
|
|
167
|
+
by an outbox is worse than no delivery at all.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT — see [LICENSE](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE).
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""stapel-realtime — the delivery substrate for the Signal primitive.
|
|
2
|
+
|
|
3
|
+
Three primitives address code: **Function** ("answer me now"), **Action**
|
|
4
|
+
("this happened, the system must know" — outbox, at-least-once), **Task**
|
|
5
|
+
("do the long work"). **Signal** is the fourth, and its addressee is a human
|
|
6
|
+
looking at a screen: *show this to whoever is watching right now*. There is no
|
|
7
|
+
obligation to an observer who is not watching — when they look, they read
|
|
8
|
+
current state over REST. Losing a signal is correct behaviour, and that is the
|
|
9
|
+
property that lets this library be simple.
|
|
10
|
+
|
|
11
|
+
``stapel_core.comm.signal()`` is the emitter — free, no-op without a backend,
|
|
12
|
+
importable by all 26 libraries. **This package is the delivery half**: the
|
|
13
|
+
Channels/Redis transport, the two consumers every browser socket is built
|
|
14
|
+
from, the wire envelope, the fail-closed authorize seam, revoke-to-kick, the
|
|
15
|
+
close-code canon, the host assembly helper, and the system checks that turn
|
|
16
|
+
today's realtime bruises into machine verdicts.
|
|
17
|
+
|
|
18
|
+
The boundary that keeps a fifth implementation from appearing (spec §2.3):
|
|
19
|
+
|
|
20
|
+
If a human in a browser is on the other end of the socket, it is
|
|
21
|
+
stapel-realtime. If it is one of our own processes, it is an
|
|
22
|
+
application-level protocol (stapel-runner-protocol) and it owes an
|
|
23
|
+
answer to "why not a Function or a Task".
|
|
24
|
+
|
|
25
|
+
Public API (lazily exported, PEP 562 — importing this package never pulls in
|
|
26
|
+
Django or Channels):
|
|
27
|
+
|
|
28
|
+
- ``realtime_settings`` — resolved app settings.
|
|
29
|
+
- ``deliver`` / ``deliver_frame`` / ``revoke`` — the delivery seam registered
|
|
30
|
+
into ``STAPEL_COMM["SIGNAL_TRANSPORT"]`` as ``"channels"``.
|
|
31
|
+
- ``EphemeralStreamConsumer`` / ``ResumableStreamConsumer`` / ``JournalRow``
|
|
32
|
+
— the consumers (need the ``channels`` extra).
|
|
33
|
+
- ``WorkspaceCapability`` — the canonical authorizer for ``ws``-scoped streams.
|
|
34
|
+
- ``build_websocket_application`` / ``collect_websocket_urlpatterns`` — host
|
|
35
|
+
assembly.
|
|
36
|
+
- ``build_stream_key`` / ``parse_stream_key`` / ``workspace_stream`` /
|
|
37
|
+
``group_name`` — the stream-key canon.
|
|
38
|
+
- ``frame`` / ``parse_frame`` / ``WIRE_VERSION`` — the wire envelope.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"realtime_settings",
|
|
43
|
+
# delivery
|
|
44
|
+
"deliver",
|
|
45
|
+
"deliver_frame",
|
|
46
|
+
"revoke",
|
|
47
|
+
# consumers
|
|
48
|
+
"BaseStreamConsumer",
|
|
49
|
+
"EphemeralStreamConsumer",
|
|
50
|
+
"ResumableStreamConsumer",
|
|
51
|
+
"JournalRow",
|
|
52
|
+
# authorization
|
|
53
|
+
"WorkspaceCapability",
|
|
54
|
+
# host assembly
|
|
55
|
+
"build_websocket_application",
|
|
56
|
+
"collect_websocket_urlpatterns",
|
|
57
|
+
# stream keys
|
|
58
|
+
"StreamKey",
|
|
59
|
+
"InvalidStreamKey",
|
|
60
|
+
"build_stream_key",
|
|
61
|
+
"parse_stream_key",
|
|
62
|
+
"workspace_stream",
|
|
63
|
+
"group_name",
|
|
64
|
+
# wire
|
|
65
|
+
"WIRE_VERSION",
|
|
66
|
+
"Frame",
|
|
67
|
+
"InvalidEnvelope",
|
|
68
|
+
"frame",
|
|
69
|
+
"parse_frame",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# name -> submodule that defines it. Resolution is deferred until first
|
|
73
|
+
# attribute access so that `import stapel_realtime` stays Django-free — and,
|
|
74
|
+
# just as important, Channels-free: a module that only builds stream keys or
|
|
75
|
+
# emits signals must not be forced to install the transport.
|
|
76
|
+
_LAZY_EXPORTS = {
|
|
77
|
+
"realtime_settings": ".conf",
|
|
78
|
+
"deliver": ".delivery",
|
|
79
|
+
"deliver_frame": ".delivery",
|
|
80
|
+
"revoke": ".delivery",
|
|
81
|
+
"BaseStreamConsumer": ".consumers",
|
|
82
|
+
"EphemeralStreamConsumer": ".consumers",
|
|
83
|
+
"ResumableStreamConsumer": ".consumers",
|
|
84
|
+
"JournalRow": ".consumers",
|
|
85
|
+
"WorkspaceCapability": ".authorize",
|
|
86
|
+
"build_websocket_application": ".asgi",
|
|
87
|
+
"collect_websocket_urlpatterns": ".asgi",
|
|
88
|
+
"StreamKey": ".streams",
|
|
89
|
+
"InvalidStreamKey": ".streams",
|
|
90
|
+
"build_stream_key": ".streams",
|
|
91
|
+
"parse_stream_key": ".streams",
|
|
92
|
+
"workspace_stream": ".streams",
|
|
93
|
+
"group_name": ".streams",
|
|
94
|
+
"WIRE_VERSION": ".envelope",
|
|
95
|
+
"Frame": ".envelope",
|
|
96
|
+
"InvalidEnvelope": ".envelope",
|
|
97
|
+
"frame": ".envelope",
|
|
98
|
+
"parse_frame": ".envelope",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def __getattr__(name):
|
|
103
|
+
if name in _LAZY_EXPORTS:
|
|
104
|
+
from importlib import import_module
|
|
105
|
+
|
|
106
|
+
value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
|
|
107
|
+
globals()[name] = value # cache for subsequent lookups
|
|
108
|
+
return value
|
|
109
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def __dir__():
|
|
113
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Django AppConfig — the one reason this L1 library is installable.
|
|
2
|
+
|
|
3
|
+
By the library standard an L1 package has no models, urls or comm surface and
|
|
4
|
+
is simply imported. This one carries system checks, and a check that is not
|
|
5
|
+
registered is a comment. So a host that actually serves WebSockets adds
|
|
6
|
+
``"stapel_realtime"`` to ``INSTALLED_APPS`` and gets the checks; a host that
|
|
7
|
+
only imports the envelope or the stream-key helpers does not have to, and
|
|
8
|
+
nothing here is touched on an HTTP-only start (Channels is never imported at
|
|
9
|
+
ready time).
|
|
10
|
+
"""
|
|
11
|
+
from django.apps import AppConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RealtimeConfig(AppConfig):
|
|
15
|
+
name = "stapel_realtime"
|
|
16
|
+
label = "realtime"
|
|
17
|
+
verbose_name = "Stapel realtime"
|
|
18
|
+
|
|
19
|
+
def ready(self):
|
|
20
|
+
from .checks import register_checks
|
|
21
|
+
from .delivery import register_transport
|
|
22
|
+
|
|
23
|
+
register_checks()
|
|
24
|
+
# Register, do not activate: the host still selects the transport with
|
|
25
|
+
# STAPEL_COMM["SIGNAL_TRANSPORT"], whose default stays "none".
|
|
26
|
+
register_transport()
|