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 ( + + ); +}; +``` + +### 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);