diff --git a/documentation/docs/library_services/presence.mdx b/documentation/docs/library_services/presence.mdx
new file mode 100644
index 00000000..b7fce559
--- /dev/null
+++ b/documentation/docs/library_services/presence.mdx
@@ -0,0 +1,565 @@
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+
+# Presence
+
+Tracks which subscribers (e.g. users, browser tabs, or other clients) are
+currently connected, automatically detecting when a connection has dropped.
+
+The library is composed of three servicers:
+- `Presence`: Each `Presence` instance (identified by an `ID`) tracks a set of
+ currently online subscribers. To track online presence in different instances
+ (e.g. multiple chat rooms or shared documents), use a different `ID` for each
+ `Presence` instance.
+- `Subscriber`: Represents an individual client connection.
+- `MousePosition`: Optional servicer for tracking and syncing mouse cursor
+ position in real time.
+
+This library also has build in [React](#react) support so you can quickly drop
+these features into your React app.
+
+
+
+## Imports and set up
+
+To use `Presence`, include its library when starting up your `Application`.
+
+
+
+```py
+from reboot.std.presence.v1.presence import presence_library
+
+async def main():
+ application = Application(
+ servicers=[MyServicer],
+ libraries=[presence_library()],
+ )
+ await application.run()
+```
+
+
+```ts
+import { presenceLibrary } from "@reboot-dev/reboot-std/presence/v1";
+
+new Application({
+ servicers: [MyServicer],
+ libraries: [presenceLibrary()],
+ initialize,
+}).run();
+```
+
+
+
+### Authorizer
+
+By default, `Presence` allows internal calls (e.g. from your Reboot backend)
+and external calls with a [verified token](/learn_more/auth#token-verification).
+However, this can be overridden by providing an
+[authorizer rule](/learn_more/auth#authorizer-rules) for all servicers in the
+presence library:
+
+
+
+```py
+application = Application(
+ servicers=[MyServicer],
+ libraries=[presence_library(
+ authorizer=allow_if(any=[has_verified_token])
+ )],
+)
+```
+
+
+```ts
+const application = new Application({
+ servicers: [MyServicer],
+ libraries: [presenceLibrary({
+ authorizer: allowIf({ all: [hasVerifiedToken] })
+ })],
+ initialize,
+});
+
+```
+
+
+
+or by providing specific service [authorizers](/learn_more/auth#authorizers):
+
+
+
+```py
+presence_authorizer = Presence.Authorizer( ... )
+
+subscriber_authorizer = Subscriber.Authorizer( ... )
+
+mouse_position_authorizer = MousePosition.Authorizer( ... )
+
+application = Application(
+ servicers=[MyServicer],
+ libraries=[presence_library(
+ presence_authorizer=presence_authorizer,
+ subscriber_authorizer=subscriber_authorizer,
+ mouse_position_authorizer=mouse_position_authorizer,
+ )],
+)
+```
+
+
+```ts
+const presenceAuthorizer = new Presence.Authorizer({ ... });
+
+const subscriberAuthorizer = new Subscriber.Authorizer({ ... });
+
+const mousePositionAuthorizer = new MousePosition.Authorizer({ ... });
+
+const application = new Application({
+ servicers: [MyServicer],
+ libraries: [presenceLibrary({
+ presenceAuthorizer,
+ subscriberAuthorizer,
+ mousePositionAuthorizer,
+ })],
+ initialize,
+});
+
+```
+
+
+
+## React
+
+The `Presence` library can be easily dropped into your React app to quicklly
+add presence tracking.
+
+Once you've [set up](/learn_more/call/from_react) your app to call
+into your Reboot API, install `@reboot-dev/reboot-std`,
+`@reboot-dev/reboot-std-api`, and `@reboot-dev/reboot-std-react` to your
+`package.json`.
+
+```bash
+npm install -S @reboot-dev/reboot-std
+npm install -S @reboot-dev/reboot-std-api
+npm install -S @reboot-dev/reboot-std-react
+```
+
+You may also want to, in defining your Reboot application, wish to [override
+the default authorizer](#authorizer).
+
+The [`Presence`](#presence-component) and
+[`MouseTracker`](#mousetracker-component) components allow you to quickly add
+these features to your app. However, you can also call the library
+[methods](#direct-library-usage) directly from React. See [Call your API from
+React](/learn_more/call/from_react) to learn more about how to do this.
+
+### Presence Component
+
+The `Presence` React component is used to
+1. Track the current client who is online, identified by a `subscriberId`.
+2. Receive a list of subscribers by ID who are currently connected.
+
+Place any components that need presence data in a `Presence` component.
+The children components can then call `usePresenceContext()` to get
+the list of online subscribers by ID.
+
+#### Properties of <Presence>
+* `id` is the `string` ID of the `Presence` instance you want to track
+the subscriber for.
+* `subscriberId` is the `string` ID of the `Subscriber` who is connecting.
+
+#### Properties returned from `usePresenceContext()`
+* `subscriberId` is the `string` ID `Subscriber` who is connecting.
+* `subscriberIds` is a `string[]` of `Subscribers` who are connected.
+
+```tsx
+import {
+ Presence,
+ usePresenceContext,
+} from "@reboot-dev/reboot-std-react/presence";
+
+// ...
+
+
+
+
+
+// ...
+
+const SubscriberList: FC<{}> = () => {
+ const { subscriberId, subscriberIds } = usePresenceContext();
+
+ return (
+
+ {subscriberIds.map((id) => (
+ - {id === subscriberId ? `${id} (you)` : id}
+ ))}
+
+ );
+};
+```
+
+### MouseTracker Component
+
+The `MouseTracker` component can be added as a child to the `Presence`
+component to sync mouse positions across clients.
+
+The `arrow` prop defines how the other synchronized cursors will appear.
+
+```tsx
+
+ ↖}
+ />
+
+```
+
+
+## Direct Library Usage
+
+If you want to use the `Presence` library directly, such as access the
+information server side, you can call the Reboot methods directly as usual.
+
+### Imports
+
+To use `Presence`, `Subscriber` or `MousePosition` servicers, import them where
+you would like to use it.
+
+
+
+
+
+
+```py
+from reboot.std.presence.v1.presence import (
+ MousePosition,
+ Presence,
+ Subscriber,
+)
+```
+
+
+
+
+
+
+
+```ts
+import { MousePosition } from "@reboot-dev/reboot-std/presence/mouse_tracker/v1";
+import { Subscriber } from "@reboot-dev/reboot-std/presence/subscriber/v1";
+import { Presence } from "@reboot-dev/reboot-std/presence/v1";
+```
+
+
+
+
+
+## Presence Methods
+
+Each `Presence` instance tracks a set of currently online subscribers. You
+can have as many `Presence` instances as you like, each with a unique ID
+(e.g. one per room, channel, or document).
+
+### Getting a reference
+
+
+
+```py
+presence = Presence.ref("my-room")
+```
+
+
+```ts
+const presence = Presence.ref("my-room");
+```
+
+
+
+### Subscribe
+
+Register a subscriber as present. The subscriber's [`status()`](#status) must
+already be `present` or you will receive a `FailedPrecondition` error.
+
+If the subscriber disconnects, they will automatically be removed from the list
+of online subscribers.
+
+
+
+```py
+await presence.subscribe(
+ context,
+ subscriber_id="user-abc",
+)
+```
+
+
+```ts
+await presence.subscribe(context, {
+ subscriberId: "user-abc",
+});
+```
+
+
+
+### List
+
+Return the IDs of all the subscribers currently connected.
+
+
+
+```py
+response = await presence.list(context)
+print(response.subscriber_ids)
+```
+
+
+```ts
+const { subscriberIds } = await presence.list(context);
+console.log(subscriberIds);
+```
+
+
+
+## Subscriber Methods
+
+Each `Subscriber` represents a single connected client.
+
+You may want to use one `Subscriber` per user or per connection. For example,
+if you want to list which users are in a chat room without listing the user
+twice if they are connected via computer or mobile device, use one `Subscriber`
+per user. If instead you want to show the user as appearing 5 times when they
+have 5 browser tabs open to your app, you would want to use one `Subscriber` for
+each of those browser tab connections.
+
+One `Subscriber` can be added to multiple `Presence` instances (e.g. to show
+a user is generally online and in specific chat rooms).
+
+### Getting a reference
+
+
+
+```py
+subscriber = Subscriber.ref("user-abc")
+```
+
+
+```ts
+const subscriber = Subscriber.ref("user-abc");
+```
+
+
+
+### Create
+
+Creates the `Subscriber` or ensures that it has already been created.
+This needs to be called before calling [`connect()`](#connect), and it is
+recommended to make this call before any [`connect()`](#connect) attempt.
+It is safe to call this method multiple times.
+
+
+
+```py
+await subscriber.create(context)
+```
+
+
+```ts
+await subscriber.create(context);
+```
+
+
+
+
+### Connect
+
+Used to initiate a subscription. This method runs indefinitely and determines
+when the subscriber is no longer present due to a dropped connection (e.g. when
+a user closes the browser tab).
+
+See the [example code](#complete-example) below for how to use `connect()`.
+
+
+### Toggle
+
+Required to confirm that [`connect()`](#connect) for a given `nonce` is live.
+The [`status()`](#status) of the `Subscriber` is not set to present *until* `toggle()` call
+is made.
+
+Requires the same `nonce` that was used for the [`connect()`](#connect) call.
+
+See the [example code](#complete-example) below for how to use `toggle()`.
+
+### Status
+
+Returns the `status` of the `Subscriber`. `present` is true if any of the
+`Subscriber`'s connections (made via a [`connect()`](#connect) and
+[`toggle()`](#toggle)) are still live. Otherwise returns false.
+
+
+
+```py
+response = await subscriber.status(context)
+print(response.present) # True if connected
+```
+
+
+```ts
+const { present } = await subscriber.status(context);
+console.log(present); // true if connected
+```
+
+
+
+### Complete Example
+
+The following is a complete example of how to create and connect a `Subscriber`
+to a `Presence` instance.
+
+
+
+
+
+
+```py
+connect_failed = False
+
+async def connect():
+ nonlocal connect_failed
+ try:
+ await subscriber_ref.Connect(context, nonce=nonce)
+ except:
+ connect_failed = True
+
+connect_task = asyncio.create_task(connect())
+
+# Retry `Toggle` and `Subscribe` as long as connection hasn't failed.
+attempt_num = 0
+while not connect_failed:
+ try:
+ print(f"Testing attempt: {attempt_num}")
+ await subscriber_ref.idempotently(
+ f"Attempt {attempt_num}",
+ ).Toggle(context, nonce=nonce)
+ except Subscriber.ToggleAborted as aborted:
+ if isinstance(aborted.error, NotFound):
+ print("Retrying Toggle")
+ attempt_num += 1
+ continue
+ raise
+
+ await presence_ref.Subscribe(
+ context, subscriber_id=subscriber_ref.state_id
+ )
+
+ # We've successfully subscribed and don't need to retry!
+ break
+```
+
+
+
+
+
+```ts
+const nonce = uuidv4();
+let connectFailed = false;
+
+await subscriberRef.idempotently().create(context);
+
+subscriberRef
+ .connect(context, { nonce })
+ .catch((_) => {
+ connectFailed = true;
+ });
+
+let attempt = 0;
+while (!connectFailed) {
+ try {
+ await subscriberRef
+ .idempotently(`attempt-${attempt}`)
+ .toggle(context, { nonce });
+ } catch (e) {
+ if (
+ e instanceof Subscriber.ToggleAborted &&
+ e.error instanceof errors_pb.NotFound
+ ) {
+ attempt++;
+ continue;
+ } else {
+ break;
+ }
+ }
+
+ await presenceRef.subscribe(context, {
+ subscriberId: subscriberRef.stateId,
+ });
+ break;
+}
+```
+
+
+
+## MousePosition Methods
+
+`MousePosition` is an optional Servicer that stores a client's current mouse
+cursor coordinates.
+
+The easiest way get started using `MousePosition` is to use the same `ID`
+as the `Subscriber`, and to use one `Subscriber` per connection.
+
+### Getting a reference
+
+
+
+```py
+mouse_position = MousePosition.ref("user-abc")
+```
+
+
+```ts
+const mousePosition = MousePosition.ref("user-abc");
+```
+
+
+
+### Update
+
+Set the current mouse position.
+
+
+
+```py
+await mouse_position.update(context, left=120, top=240)
+```
+
+
+```ts
+await mousePosition.update(context, { left: 120, top: 240 });
+```
+
+
+
+### Position
+
+Returns the current mouse position.
+
+
+
+```py
+response = await mouse_position.position(context)
+print(response.left, response.top)
+```
+
+
+```ts
+const { left, top } = await mousePosition.position(context);
+console.log(left, top);
+```
+
+
diff --git a/documentation/sidebars.js b/documentation/sidebars.js
index 44ab122e..1224c4b8 100644
--- a/documentation/sidebars.js
+++ b/documentation/sidebars.js
@@ -145,6 +145,7 @@ const sidebars = {
"library_services/mailgun",
"library_services/oauth_token_manager",
"library_services/ordered_map",
+ "library_services/presence",
"library_services/pubsub",
"library_services/queue",
"library_services/sorted_map",
diff --git a/reboot/std/react/presence/index.tsx b/reboot/std/react/presence/index.tsx
index 642b4f49..4e1499a2 100644
--- a/reboot/std/react/presence/index.tsx
+++ b/reboot/std/react/presence/index.tsx
@@ -118,8 +118,9 @@ const MouseArrow: FC<{ id: string; arrow: ReactNode }> = ({ id, arrow }) => {
return (
= ({ arrow, className, style, children }) => {
const { subscriberId, subscriberIds } = usePresenceContext();
diff --git a/tests/reboot/std/collections/queue/v1/queue_tests.py b/tests/reboot/std/collections/queue/v1/queue_tests.py
index 78f2ce17..b7cbd5ef 100644
--- a/tests/reboot/std/collections/queue/v1/queue_tests.py
+++ b/tests/reboot/std/collections/queue/v1/queue_tests.py
@@ -4,7 +4,7 @@
from reboot.aio.tests import Reboot
from reboot.protobuf import as_int, as_str, from_int, from_str, pack, unpack
-# Import used in Queue documentation, so we want to keep them
+# Import used in Queue documentation, so we want to keep them separate.
# isort: off
from reboot.std.collections.queue.v1.queue import Queue
from reboot.std.item.v1.item import Item
diff --git a/tests/reboot/std/presence/BUILD.bazel b/tests/reboot/std/presence/BUILD.bazel
index e23fb162..6c4f9df2 100644
--- a/tests/reboot/std/presence/BUILD.bazel
+++ b/tests/reboot/std/presence/BUILD.bazel
@@ -16,6 +16,7 @@ ts_project(
name = "presence_tests_ts",
srcs = [
"presence_tests.ts",
+ "subscriber_connect.ts",
":package.json",
],
declaration = True,
@@ -33,6 +34,8 @@ ts_project(
"//:node_modules/@reboot-dev/reboot-api",
"//:node_modules/@reboot-dev/reboot-std",
"//:node_modules/@types/node",
+ # Required to get uuid package.
+ "//tests/reboot:greeter_js_reboot",
],
)
diff --git a/tests/reboot/std/presence/presence_tests.py b/tests/reboot/std/presence/presence_tests.py
index 812d93f2..21381567 100644
--- a/tests/reboot/std/presence/presence_tests.py
+++ b/tests/reboot/std/presence/presence_tests.py
@@ -14,13 +14,19 @@
from reboot.aio.external import ExternalContext
from reboot.aio.memoize import MemoizeServicer
from reboot.aio.tests import Reboot
+
+# Import used in Presence documentation, so we want to keep them separate.
+# isort: off
from reboot.std.presence.v1.presence import (
- ListResponse,
MousePosition,
- PositionResponse,
Presence,
- StatusResponse,
Subscriber,
+)
+# isort: on
+from reboot.std.presence.v1.presence import (
+ ListResponse,
+ PositionResponse,
+ StatusResponse,
presence_library,
)
from typing import Optional
diff --git a/tests/reboot/std/presence/presence_tests.ts b/tests/reboot/std/presence/presence_tests.ts
index 9558cb31..ca270c04 100644
--- a/tests/reboot/std/presence/presence_tests.ts
+++ b/tests/reboot/std/presence/presence_tests.ts
@@ -6,12 +6,16 @@ import {
TokenVerifier,
allow,
} from "@reboot-dev/reboot";
+import { fork } from "child_process";
import { errors_pb } from "@reboot-dev/reboot-api";
import { MousePosition } from "@reboot-dev/reboot-std/presence/mouse_tracker/v1";
import { Subscriber } from "@reboot-dev/reboot-std/presence/subscriber/v1";
-import { Presence, presenceLibrary } from "@reboot-dev/reboot-std/presence/v1";
+import { Presence } from "@reboot-dev/reboot-std/presence/v1";
+// eslint-disable-next-line
+import { presenceLibrary } from "@reboot-dev/reboot-std/presence/v1";
import { strict as assert } from "node:assert";
import { test } from "node:test";
+import * as uuid from "uuid";
class EmptyTokenVerifier extends TokenVerifier {
async verifyToken(
@@ -169,4 +173,81 @@ test("Use Presence Servicers", async (t) => {
});
}
);
+
+ await t.test("Subscriber connection", async (t) => {
+ await rbt.up(
+ new Application({
+ libraries: [presenceLibrary()],
+ tokenVerifier: new EmptyTokenVerifier(),
+ }),
+ {
+ // needed so URL starts with http:
+ localEnvoy: true,
+ }
+ );
+
+ let context = rbt.createExternalContext("test-connect");
+ let subscriberRef = Subscriber.ref("connect-test-subscriber");
+
+ await subscriberRef.idempotently().create(context);
+ let nonce = uuid.v4();
+
+ // Connect the subscriber. The following would work if we could cancel
+ // the promise/RPC so the test could end. However, because we can't, we use
+ // a subprocess instead. Left here to grab for documentation.
+
+ // let connectFailed = false;
+ // const promise = subscriberRef
+ // .connect(context, { nonce })
+ // .catch((_) => {
+ // connectFailed = true;
+ // });
+
+ const subprocess = fork(
+ "./tests/reboot/std/presence/subscriber_connect.js",
+ [rbt.url(), subscriberRef.stateId, nonce]
+ );
+
+ let attempt = 0;
+ while (true) {
+ try {
+ await subscriberRef
+ .idempotently(`attempt-${attempt}`)
+ .toggle(context, { nonce });
+ } catch (e) {
+ if (
+ e instanceof Subscriber.ToggleAborted &&
+ e.error instanceof errors_pb.NotFound
+ ) {
+ attempt++;
+ continue;
+ } else {
+ break;
+ }
+ }
+
+ await Presence.ref("connect-test").subscribe(context, {
+ subscriberId: subscriberRef.stateId,
+ });
+ break;
+ }
+
+ let { present } = await subscriberRef.status(context);
+ assert(present);
+
+ // Tell the child process it can exit.
+ subprocess.send("");
+
+ await new Promise((resolve, reject) => {
+ subprocess.on("exit", (code, signal) => {
+ if (code === 0) {
+ resolve();
+ } else if (signal === null) {
+ reject(new Error(`Child exited with code ${code}`));
+ } else {
+ reject(new Error(`Child exited with signal ${signal}`));
+ }
+ });
+ });
+ });
});
diff --git a/tests/reboot/std/presence/subscriber_connect.ts b/tests/reboot/std/presence/subscriber_connect.ts
new file mode 100644
index 00000000..cc168c4a
--- /dev/null
+++ b/tests/reboot/std/presence/subscriber_connect.ts
@@ -0,0 +1,22 @@
+import { ExternalContext } from "@reboot-dev/reboot";
+import { Subscriber } from "@reboot-dev/reboot-std/presence/subscriber/v1";
+
+const args = process.argv.slice(2);
+const url = args[0];
+const subscriberId = args[1];
+const nonce = args[2];
+
+const context = new ExternalContext({ name: "subscriber-connect", url });
+const subscriber = Subscriber.ref(subscriberId);
+
+subscriber.connect(context, { nonce });
+
+await new Promise((resolve) => {
+ process.once("message", () => {
+ resolve();
+ });
+});
+
+// Need to explicitly exit because the call to `testLongRunningWriter`
+// should still be outstanding.
+process.exit(0);