Metadata-Version: 2.4
Name: stapel-realtime
Version: 0.2.0
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<1.0,>=0.45.0
Provides-Extra: channels
Requires-Dist: stapel-core[channels]<1.0,>=0.45.0; 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]<1.0,>=0.45.0; 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)
[![pypi](https://img.shields.io/pypi/v/stapel-realtime?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-realtime/)
[![downloads](https://static.pepy.tech/badge/stapel-realtime/month)](https://pepy.tech/project/stapel-realtime)
[![python](https://img.shields.io/pypi/pyversions/stapel-realtime?logo=python&logoColor=white)](https://pypi.org/project/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, the fleet's presence oracle, and seven system checks. Presence is a TTL lease in the fleet-shared cache (stapel_core.core.fleet_cache), written by the base consumer on connect, on every heartbeat tick and on disconnect — no model, no migration — and read by two comm Functions: realtime.is_live {user_id, family?} -> {live, sessions, last_seen} and realtime.live_batch {user_ids[<=100], family?} -> {users: {id: {...}}}. That pair is the whole comm surface; there are no models, migrations, views or urls, and no HTTP route of its own, so a peer asks over the bus like any other Function. It is installed as a Django app so the checks and the Functions 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

```bash
pip install stapel-realtime
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.2.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13) |
| Config axes | 9 |
| Usage surface | 23 |
| 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. |
| **Presence** | The fleet's answer to "is this person watching *right now*?" — a TTL lease in the shared cache, written by the base consumer on connect / heartbeat / disconnect, read over the bus as `realtime.is_live` and `realtime.live_batch`. No model, no migration. |
| **System checks** | Seven, 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.

## Presence: who is watching right now

Every sender of a Signal eventually needs the question the substrate is the only
place able to answer. The first to need it was an incoming call: the ring is
pushed to the callee's phone *and* rung in the tab they already have open, and
with nothing to ask, the push went out unconditionally and every client carried
the workaround of suppressing a banner for a call it was already ringing.

The base consumer writes a **TTL lease** into the fleet-shared cache on connect,
on every heartbeat tick, and on disconnect. Anyone asks over the bus:

```python
from stapel_core.comm import call

if not call("realtime.is_live", {"user_id": str(callee_id)})["live"]:
    notify(callee_id, "call.incoming", ...)   # nobody is looking; push it

# or, for a group, in one round trip (≤100 ids, every id comes back)
live = call("realtime.live_batch", {"user_ids": ids})["users"]
```

`{"live": bool, "sessions": int, "last_seen": iso|null}` — `sessions` counts
open sockets, so two tabs are two sessions and one person. An optional
`"family"` narrows the question to one stream family (`chat`, `video`, …).

Four properties worth knowing before you gate anything on it:

- **It is a lease, not a counter.** A worker killed mid-socket never runs its
  `disconnect`; one `PRESENCE_TTL_S` later that session simply stops counting.
  Which is why the TTL must stay above `HEARTBEAT_S` — `realtime.W006`.
- **It fails to "not live".** No cache, dead redis, corrupt document: the
  answer is `false` and nothing raises. A caller gating a push therefore falls
  back to sending it, which is exactly the behaviour that existed before.
- **It is fleet-shared on purpose.** The write goes through
  `stapel_core.core.fleet_cache`, not `django.core.cache`, because the service
  holding the socket is not the service asking. On a locmem cache it is
  per-process and `realtime.W005` says so.
- **It is not a last-seen history.** `last_seen` outlives the session by one
  TTL and no longer. A durable "last online" belongs to a profile row.

## 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: 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. And a `presence.changed` signal:
presence is *asked*, not announced, because a fan-out on every connect and
disconnect in the fleet is a great deal of traffic for a fact that costs one
cache read — a module that wants to paint a green dot subscribes to the one its
own domain already emits (`chat.presence.changed`).

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