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.
Files changed (37) hide show
  1. stapel_realtime-0.1.1/CONFIG.MD +56 -0
  2. stapel_realtime-0.1.1/LICENSE +21 -0
  3. stapel_realtime-0.1.1/PKG-INFO +215 -0
  4. stapel_realtime-0.1.1/README.md +175 -0
  5. stapel_realtime-0.1.1/__init__.py +113 -0
  6. stapel_realtime-0.1.1/apps.py +26 -0
  7. stapel_realtime-0.1.1/asgi.py +200 -0
  8. stapel_realtime-0.1.1/authorize.py +119 -0
  9. stapel_realtime-0.1.1/checks.py +214 -0
  10. stapel_realtime-0.1.1/close_codes.py +92 -0
  11. stapel_realtime-0.1.1/conf.py +66 -0
  12. stapel_realtime-0.1.1/consumers.py +458 -0
  13. stapel_realtime-0.1.1/delivery.py +173 -0
  14. stapel_realtime-0.1.1/docs/capabilities.json +284 -0
  15. stapel_realtime-0.1.1/docs/llms.txt +89 -0
  16. stapel_realtime-0.1.1/envelope.py +187 -0
  17. stapel_realtime-0.1.1/py.typed +0 -0
  18. stapel_realtime-0.1.1/pyproject.toml +115 -0
  19. stapel_realtime-0.1.1/schemas/wire/envelope.v1.json +96 -0
  20. stapel_realtime-0.1.1/setup.cfg +4 -0
  21. stapel_realtime-0.1.1/stapel_realtime.egg-info/PKG-INFO +215 -0
  22. stapel_realtime-0.1.1/stapel_realtime.egg-info/SOURCES.txt +52 -0
  23. stapel_realtime-0.1.1/stapel_realtime.egg-info/dependency_links.txt +1 -0
  24. stapel_realtime-0.1.1/stapel_realtime.egg-info/requires.txt +18 -0
  25. stapel_realtime-0.1.1/stapel_realtime.egg-info/top_level.txt +1 -0
  26. stapel_realtime-0.1.1/streams.py +118 -0
  27. stapel_realtime-0.1.1/testing.py +142 -0
  28. stapel_realtime-0.1.1/tests/test_asgi.py +176 -0
  29. stapel_realtime-0.1.1/tests/test_authorize.py +100 -0
  30. stapel_realtime-0.1.1/tests/test_checks.py +161 -0
  31. stapel_realtime-0.1.1/tests/test_consumers_ephemeral.py +314 -0
  32. stapel_realtime-0.1.1/tests/test_consumers_resumable.py +185 -0
  33. stapel_realtime-0.1.1/tests/test_contract.py +91 -0
  34. stapel_realtime-0.1.1/tests/test_delivery.py +146 -0
  35. stapel_realtime-0.1.1/tests/test_envelope.py +149 -0
  36. stapel_realtime-0.1.1/tests/test_public_api.py +124 -0
  37. 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
+ [![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-realtime/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-realtime/actions/workflows/ci.yml?query=branch%3Amain)
46
+ [![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-realtime?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-realtime)
47
+ [![status](https://img.shields.io/badge/status-unreleased-orange)](https://github.com/usestapel/stapel-realtime)
48
+ [![license](https://img.shields.io/github/license/usestapel/stapel-realtime)](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE)
49
+ [![llms.txt](https://img.shields.io/badge/llms.txt-blue)](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
+ [![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-realtime/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-realtime/actions/workflows/ci.yml?query=branch%3Amain)
6
+ [![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-realtime?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-realtime)
7
+ [![status](https://img.shields.io/badge/status-unreleased-orange)](https://github.com/usestapel/stapel-realtime)
8
+ [![license](https://img.shields.io/github/license/usestapel/stapel-realtime)](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE)
9
+ [![llms.txt](https://img.shields.io/badge/llms.txt-blue)](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()