Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 50 additions & 17 deletions documentation/docs/call/from_outside_your_app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,39 +95,72 @@ const context = new ExternalContext({

## Calling readers reactively

Using an `ExternalContext`, you can also call `reader` methods
_reactively_: you receive a new answer every time the state changes.
Each answer is a pair of a `response` and an `aborted`: the reader's
response, or the error it answered with (a declared error, a denied
`authorizer()`, a state that is not constructed yet). An error does
not end the read; Reboot keeps the subscription open and yields again
once the state changes, so you decide whether to keep waiting, to
`break`, or to `raise aborted`. Failed connections are retried for
you. For example:
You can also call `reader` methods _reactively_: you receive a new
result every time the state changes. Each result is either the reader's
`response` or the error it raised (`aborted`): a declared error, a
denied `authorizer()`, a state that does not exist yet. An error does
not end the read: the subscription stays open and yields again once the
state changes, so you decide whether to keep reading, stop, or raise the
error yourself. Failed connections are retried for you.

:::note

The Node.js `ExternalContext` cannot read reactively yet. From Node.js,
you can use a `WebContext` from `@reboot-dev/reboot-web` instead.
Running it in Node.js is not supported beyond the case shown below. It
needs the `fetch` and `WebSocket` globals (Node.js 22 has both by
default; Node.js 20 needs `--experimental-websocket`).

:::

For example:

<Tabs groupId="language">
<TabItem value="python" label="Python" default>
<!-- MARKDOWN-AUTO-DOCS:START
(CODE:src=../../../reboot/demos/fig/backend/src/many_readers.py&lines=20-25) -->
<!-- The below code snippet is automatically added from ../../../reboot/demos/fig/backend/src/many_readers.py -->
(CODE:src=../../../tests/reboot/documentation/chat_room.py&lines=39-49) -->
<!-- The below code snippet is automatically added from ../../../tests/reboot/documentation/chat_room.py -->

```py
fig = Fig.ref(fig_id)
async for response, aborted in fig.reactively().get_position(context):
async for response, aborted in chat_room.reactively().messages(context):
if aborted is not None:
print(f"{fig_id}: {aborted}")
# The reader raised an error, e.g., the chat room does not
# exist yet. The read continues and yields again once the
# state changes.
print(f"Could not read messages: {aborted}")
continue
print(f"{fig_id}: {response}")
assert response is not None
print(response.messages)
if "Hello, World!" in response.messages:
break
```

<!-- MARKDOWN-AUTO-DOCS:END -->

</TabItem>
<TabItem value="typescript" label="TypeScript">
<!-- TODO: use markdown-autodoc to get this from tested code. -->
<!-- MARKDOWN-AUTO-DOCS:START
(CODE:src=../../../tests/reboot/documentation/chat_room.ts&lines=60-73) -->
<!-- The below code snippet is automatically added from ../../../tests/reboot/documentation/chat_room.ts -->

```ts
// COMING SOON!
const [responses] = await chatRoom.reactively().messages(context);
for await (const { response, aborted } of responses) {
if (aborted !== undefined) {
// The reader raised an error, e.g., the chat room does not
// exist yet. The read continues and yields again once the
// state changes.
console.log(`Could not read messages: ${aborted.message}`);
continue;
}
console.log(response.messages);
if (response.messages.includes("Hello, World!")) {
break;
}
}
```

<!-- MARKDOWN-AUTO-DOCS:END -->
</TabItem>
</Tabs>

Expand Down
9 changes: 9 additions & 0 deletions documentation/docs/call/from_react.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -601,4 +601,13 @@ outage. These retries are _idempotent_, so this is perfectly safe to do.
An error is considered unretryable if it originates from the application. All
other errors are retried with an exponential backoff.

A reader hook never stops retrying. If the connection drops, the hook
reconnects with backoff; while it reconnects, `isLoading` is `true` and
`response` and `aborted` keep their last values. If the reader raises an
error (a declared error, a denied `authorizer()`, `StateNotConstructed`),
the hook sets `aborted` to that error, clears `response`, and reconnects
with backoff. When the reader later returns a response, the hook sets
`response` and clears `aborted`. If the error means the read should not
continue, stop rendering the hook.

<!-- See the section on [errors](#errors) for more details. -->
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ResponseOrAborted } from "@reboot-dev/reboot-web";
import { WebContext } from "@reboot-dev/reboot-web";
import { ChatRoom } from "../../api/chat_room/v1/chat_room_rbt_web";
import {
ChatRoom,
ChatRoomMessagesAborted,
} from "../../api/chat_room/v1/chat_room_rbt_web";

const root = document.getElementById("messages");
const button = document.getElementById("button");
Expand Down Expand Up @@ -29,9 +33,18 @@ async function handleClick(element: HTMLInputElement) {

async function bindToElement(
element: HTMLElement,
generator: AsyncGenerator<ChatRoom.MessagesResponse>
generator: AsyncGenerator<
ResponseOrAborted<ChatRoom.MessagesResponse, ChatRoomMessagesAborted>
>
) {
for await (const response of generator) {
for await (const { response, aborted } of generator) {
if (aborted !== undefined) {
// The reader raised an error, e.g., the chat room has not been
// created yet. The read keeps going and yields again once the
// state changes.
element.innerHTML = `<div class="message">${aborted.message}</div>`;
continue;
}
element.innerHTML = `${response.messages
.map((msg: string) => `<div class="message">${msg}</div>`)
.join("")}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,43 @@ Also:
`response, aborted = await anext(subscription)`.
- A reader whose response type is empty yields `(None, None)` on
success, so check `aborted`, not `response`, for those.

## Web `reactively()` reads yield `{ response, aborted }` objects

The non-React web client's `Type.ref(id).reactively().<reader>(context)`
generator (`@reboot-dev/reboot-web`) used to yield bare responses and
to swallow every error the reader raised, silently reconnecting
forever, so a `for await` over it simply went quiet on a declared
error, a denied `authorizer()`, or `StateNotConstructed`. It now
yields a `ResponseOrAborted` object for every result: `{ response }`
for a response, `{ aborted }` (the method's `<Type><Method>Aborted`)
for an error. An error does not end the read: the generator keeps going
and yields again once the state changes. It never throws.

Find every use of such a read, matching only the opening parenthesis
since a formatter may have split the call across lines:

grep -rn "\.reactively(" --include=*.ts --include=*.tsx --include=*.js

Destructure the item in every `for await` over one. Before:

```ts
for await (const response of responses) {
render(response);
}
```

After:

```ts
for await (const { response, aborted } of responses) {
if (aborted !== undefined) {
// Decide: `continue` to wait for the state to change, `break` to
// stop reading, or `throw aborted`.
continue;
}
render(response);
}
```

Code that pulls items with `responses.next()` gets the object too.
20 changes: 14 additions & 6 deletions reboot/templates/reboot_web.ts.j2
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ class _Reactively {
partialRequest?: {{ client.proto.state_name }}.Partial{{ method.proto.name }}Request,
options?: { signal?: AbortSignal },
): Promise<[
AsyncGenerator<{{ client.proto.state_name }}.{{ method.proto.name }}Response, void, unknown>,
AsyncGenerator<reboot_web.ResponseOrAborted<{{ client.proto.state_name }}.{{ method.proto.name }}Response, {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted>, void, unknown>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious for all of the spellings of the aborted types, is there not a ChatRoom.MessagesAborted only a ChatRoomMessagesAborted?

If only the latter exists then no changes necessary, but if the former exists it would be great to use it everywhere!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the ChatRoomMessgesAborted type exists in web and React.

(newRequest: {{ client.proto.state_name }}.Partial{{ method.proto.name }}Request) => void
]> {
const request = {{ client.proto.state_name }}{{ method.proto.name }}RequestToProtobuf(partialRequest);
Expand All @@ -458,9 +458,14 @@ class _Reactively {
id: this.#id,
requestType: {{ method.input_type }},
responseType: {{ method.output_type }},
abortedType: {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted,
request: request,
signal: options?.signal,
bearerToken: context.bearerToken,
// Read the token from `context` on every attempt, so that a
// token set with `context.setBearerToken()` after the read
// started is sent on the next attempt.
bearerToken: async () => await context.bearerToken?.(),
onUnauthenticated: context.onUnauthenticated,
websockets: context.websockets,
}
);
Expand All @@ -470,10 +475,13 @@ class _Reactively {
setRequest(typedRequest);
};

async function* typedGenerator(): AsyncGenerator<{{ client.proto.state_name }}.{{ method.proto.name }}Response, void, unknown> {
for await (const response of generator) {
const typedResponse = {{ client.proto.state_name }}{{ method.proto.name }}ResponseFromProtobufShape(response);
yield typedResponse;
async function* typedGenerator(): AsyncGenerator<reboot_web.ResponseOrAborted<{{ client.proto.state_name }}.{{ method.proto.name }}Response, {{ client.proto.state_name | to_camel }}{{ method.proto.name | to_camel }}Aborted>, void, unknown> {
for await (const { response, aborted } of generator) {
if (aborted !== undefined) {
yield { aborted };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming aborted doesn't need to go through any kind of protobuf shape transformation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. The response gets converted at the yield but aborted here is already the correct shape, an instance of the generated Aborted class.

continue;
}
yield { response: {{ client.proto.state_name }}{{ method.proto.name }}ResponseFromProtobufShape(response) };
}
};

Expand Down
Loading
Loading