Metadata-Version: 2.4
Name: stapel-realtime
Version: 0.1.1
Summary: Realtime delivery substrate for the Stapel framework — the Signal primitive on the wire
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-realtime
Project-URL: Repository, https://github.com/usestapel/stapel-realtime
Project-URL: Documentation, https://github.com/usestapel/stapel-realtime#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-realtime/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-realtime/issues
Keywords: django,stapel,realtime,websocket,channels,signal
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stapel-core<0.34,>=0.33.2
Provides-Extra: channels
Requires-Dist: stapel-core[channels]<0.34,>=0.33.2; extra == "channels"
Requires-Dist: channels<5,>=4.0; extra == "channels"
Provides-Extra: redis
Requires-Dist: channels-redis<5,>=4.2; extra == "redis"
Provides-Extra: testing
Requires-Dist: channels<5,>=4.0; extra == "testing"
Requires-Dist: daphne<5,>=4; extra == "testing"
Provides-Extra: all
Requires-Dist: stapel-core[channels]<0.34,>=0.33.2; extra == "all"
Requires-Dist: channels<5,>=4.0; extra == "all"
Requires-Dist: channels-redis<5,>=4.2; extra == "all"
Requires-Dist: daphne<5,>=4; extra == "all"
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-realtime

[![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)
[![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)
[![status](https://img.shields.io/badge/status-unreleased-orange)](https://github.com/usestapel/stapel-realtime)
[![license](https://img.shields.io/github/license/usestapel/stapel-realtime)](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-realtime/blob/main/docs/llms.txt)

> 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.

Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

Not published on PyPI yet. Install from source:

```bash
pip install git+https://github.com/usestapel/stapel-realtime
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.1.1` |
| Python | `>=3.11` (3.11, 3.12, 3.13) |
| Config axes | 8 |
| Usage surface | 17 |
| Extension points | 6 |
| Fleet dependencies | [`stapel-core`](https://github.com/usestapel/stapel-core) |

## Documentation

[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)

## What this is

**The delivery half of the fourth communication primitive.**

Three primitives in `stapel_core.comm` address code. **Function** — "answer me
now", the caller waits. **Action** — "this happened, the system must know":
outbox, at-least-once, 0..N module subscribers. **Task** — "do the long work",
the system waits, not the caller.

**Signal** is the fourth, and its addressee is a human looking at a screen:

> Show this to whoever is watching right now.

There is no obligation to an observer who is not watching. When they look, they
read current state over REST — the truth is in the database, and the value of a
signal expires in seconds. **Losing a signal is correct behaviour**, and that
one property is what lets this library be small: no outbox row, no retry, no
history, no delivery receipt.

`stapel_core.comm.signal()` is the emitter — sixty lines of stdlib, free for
every library in the fleet, a silent no-op with no backend configured. This
package is everything on the other side of that call.

## What it ships

| | |
|---|---|
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **Revoke → kick** | `revoke(stream_key, user_id)` sends a `kick` frame and closes 4410 immediately, rather than leaking until the client happens to reconnect. |
| **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. |
| **System checks** | Five, each one a production bruise turned into a `manage.py check` verdict. |
| **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. |

## Quick start

```python
# myapp/consumers.py
from stapel_realtime import EphemeralStreamConsumer, WorkspaceCapability

class RecordingsConsumer(EphemeralStreamConsumer):
    module = "recordings"
    scope_type = "ws"
    stream_key_kwarg = "workspace_id"
    authorizer = WorkspaceCapability("recordings.read")
```

```python
# myapp/routing.py — the manifest the host assembly discovers
from django.urls import path
from .consumers import RecordingsConsumer

websocket_urlpatterns = [
    path("ws/recordings/<uuid:workspace_id>", RecordingsConsumer.as_asgi()),
]
```

```python
# myapp/services.py — the emit side. Note what is NOT imported: a module that
# only signals depends on the core, never on this library.
from stapel_core.comm import signal, stream_key

with transaction.atomic():
    recording.status = "ready"
    recording.save()
    signal(stream_key("recordings", "ws", recording.workspace_id),
           "recording.status",
           {"recording_id": str(recording.pk), "status": recording.status})
```

```python
# asgi.py — the whole host
from django.core.asgi import get_asgi_application
from stapel_realtime.asgi import build_websocket_application

application = build_websocket_application(http_application=get_asgi_application())
```

```python
# settings.py
INSTALLED_APPS += ["stapel_realtime"]      # so the system checks are registered

STAPEL_COMM = {"SIGNAL_TRANSPORT": "channels"}   # opt in; the default is "none"

STAPEL_REALTIME = {
    "ALLOWED_ORIGINS": ["https://app.example.com"],   # WITH the port if non-default
}
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {"hosts": ["redis://redis:6379/0"]},
    }
}
```

Install: `pip install 'stapel-realtime[channels,redis]'` on a host that serves
sockets, and `[testing]` on top wherever a module tests its own consumer (that
extra adds daphne, which `channels.testing` drags in — no reason to put an ASGI
server on a production host). A module that only *emits* needs nothing from
here at all: `comm.signal()` lives in the core, and that is the point.

## The rule that keeps a fifth implementation from appearing

Before this library the fleet had **three** independent browser sockets (chat,
video, studio-dialog) plus a machine peer protocol, each with its own JWT
handling, its own close codes, and — twice, independently — its own resume
protocol. The boundary is drawn by *who is on the other end*:

> **A human in a browser → `stapel-realtime`.**
> **One of our own processes → an application-level protocol**
> (`stapel-runner-protocol`), and it owes an answer to "why not a Function or a
> Task".

The machine protocol stays separate on merit, not inertia: a dropped
`task.assign` frame is unacceptable where a dropped signal is correct, it needs
exactly-once apply keyed by `(task_id, seq)`, and it is deliberately
transport-agnostic so it can be tested without a network.

## What it does not do

Not in v1, on purpose: a presence registry, an SSE fallback, one multiplexed
socket for many streams (the envelope reserves `stream` so adding it later is
not a breaking change), NATS as the signal transport (that is a future value of
the core's axis, for the microservice topology), client→server commands over
the socket (writes go through REST/Function), and delivering Actions to the
browser as-is — an anti-pattern, because a five-minute-late "typing…" retried
by an outbox is worse than no delivery at all.

## License

MIT — see [LICENSE](https://github.com/usestapel/stapel-realtime/blob/main/LICENSE).

---

<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>
