Skip to content

Commit 83492dc

Browse files
authored
Merge pull request #1036 from streamich/server-demo-improvements
chore: 🤖 improve demo setup
2 parents 9671777 + 3c5f2fe commit 83492dc

156 files changed

Lines changed: 5048 additions & 231 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/channel/docs/index.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
11
import type {LibPage} from '@jsonjoy.com/ui/src/types/libs';
22

33
export const page: LibPage = {
4-
name: 'channel',
5-
title: 'channel',
4+
name: 'Channel',
65
type: 'lib',
76
subtitle: 'Bidirectional communication channel abstraction over WebSocket, fetch, and more.',
8-
children: [],
97
pkg: '@jsonjoy.com/channel',
108
group: 'sync',
119
repo: 'streamich/json-joy',
1210
repoPath: 'tree/master/packages/channel',
1311
tech: 'TypeScript',
1412
techIcon: {set: 'lineicons', icon: 'typescript'},
13+
showContentsTable: true,
14+
children: [
15+
{
16+
name: 'Physical channel',
17+
subtitle: 'The PhysicalChannel interface, states, and lifecycle events.',
18+
// @ts-ignore raw markdown, loaded by the site's webpack raw-loader
19+
src: async () => (await import('./physical-channel.md')).default,
20+
},
21+
{
22+
name: 'Transports',
23+
subtitle: 'WebSocketChannel, FetchPhysicalChannel, Utf8Channel.',
24+
// @ts-ignore
25+
src: async () => (await import('./transports.md')).default,
26+
},
27+
{
28+
name: 'Persistent channel',
29+
subtitle: 'PersistentPhysicalChannel: auto-reconnect with configurable backoff.',
30+
// @ts-ignore
31+
src: async () => (await import('./persistent-channel.md')).default,
32+
},
33+
],
1534
// @ts-ignore
1635
src: async () => (await import('./text.md')).default,
1736
};
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
## Persistent channel
2+
3+
`PersistentPhysicalChannel<T>` keeps a connection alive across drops. It is **not** itself
4+
a `PhysicalChannel` --- it owns one at a time and recreates
5+
it via the `newChannel` factory whenever the previous one closes.
6+
7+
```ts
8+
import {PersistentPhysicalChannel, WebSocketChannel}
9+
from '@jsonjoy.com/channel';
10+
11+
const persistent = new PersistentPhysicalChannel<Uint8Array>({
12+
newChannel: () =>
13+
new WebSocketChannel({
14+
newSocket: () =>
15+
new WebSocket('wss://example.com/rx')
16+
}),
17+
});
18+
19+
persistent.start();
20+
persistent.message$.subscribe((data) => handle(data));
21+
```
22+
23+
24+
## Options
25+
26+
| Option | Default | Description |
27+
|---|---|---|
28+
| `newChannel` | required | Called on every (re)connect to build a fresh channel |
29+
| `minReconnectionDelay` | 1000--2000 ms | Floor for the first backoff |
30+
| `maxReconnectionDelay` | 10000 ms | Ceiling for the backoff |
31+
| `reconnectionDelayGrowFactor` | 1.3 | Base of the exponential backoff |
32+
| `minUptime` | 5000 ms | A connection must stay open this long to reset the retry counter |
33+
34+
35+
## Lifecycle
36+
37+
```ts
38+
persistent.start(); // begin keeping a connection open
39+
persistent.stop(); // close, dispose, no further use possible
40+
```
41+
42+
After `stop()`, all subjects are completed and the instance cannot be
43+
restarted. Construct a new one if you need to reconnect later.
44+
45+
46+
## Observable surface
47+
48+
| Stream | Emits |
49+
|---|---|
50+
| `active$` | `true` while `start()` was called and not yet stopped |
51+
| `channel$` | The current underlying `PhysicalChannel`, or `undefined` |
52+
| `open$` | `true` whenever the active channel is open, `false` otherwise |
53+
| `message$` | Incoming messages from whichever underlying channel is current |
54+
| `error$` | Errors from the channel factory or underlying channels |
55+
56+
57+
## Sending
58+
59+
```ts
60+
persistent.send$(payload).subscribe();
61+
```
62+
63+
`send$` waits for the next open connection, then sends. There is no
64+
synchronous `send` --- by design, the caller is forced to opt into the
65+
waiting behavior, which is what you almost always want when reconnect can
66+
happen at any time.
67+
68+
69+
## Backoff
70+
71+
`reconnectDelay()` returns the wait time before the next attempt:
72+
73+
```
74+
delay = min(
75+
maxReconnectionDelay,
76+
minReconnectionDelay * reconnectionDelayGrowFactor ** (retries - 1)
77+
)
78+
```
79+
80+
If a connection stays open longer than `minUptime`, the retry counter
81+
resets to 0 --- the next disconnect will reconnect quickly.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
## PhysicalChannel
2+
3+
`PhysicalChannel<T>` is the interface every concrete transport implements.
4+
`T` is the message payload type --- either `string`, `Uint8Array`, or the
5+
union of both.
6+
7+
```ts
8+
interface PhysicalChannel<T extends string | Uint8Array> {
9+
// Reactive surface
10+
state$: BehaviorSubject<ChannelState>;
11+
open$: Observable<PhysicalChannel<T>>;
12+
close$: Observable<[self: PhysicalChannel<T>, event: CloseEventBase]>;
13+
error$: Observable<Error>;
14+
message$: Observable<T>;
15+
16+
// Imperative surface
17+
closed: boolean;
18+
onmessage?: (data: T, isUtf8: boolean) => void;
19+
onclose?: (code: number, reason: string, wasClean: boolean) => void;
20+
21+
isOpen(): boolean;
22+
send(data: T): number; // returns bytes buffered, -1 if not ready
23+
send$(data: T): Observable<number>; // waits for open, then sends
24+
close(code?, reason?): void;
25+
buffer(): number; // bytes currently buffered out
26+
}
27+
```
28+
29+
30+
## States
31+
32+
The `ChannelState` enum reflects the connection lifecycle:
33+
34+
| Value | Meaning |
35+
|---|---|
36+
| `CONNECTING` | Initial; not yet open |
37+
| `OPEN` | Ready to send/receive |
38+
| `CLOSED` | Terminal; cannot be reopened |
39+
40+
`state$` is a `BehaviorSubject`, so subscribers get the current state
41+
immediately. `open$` and `close$` are `ReplaySubject(1)` --- subscribing
42+
after the event still fires the callback.
43+
44+
45+
## Two ways to subscribe
46+
47+
`message$` (observable) and `onmessage` (callback) deliver the same data.
48+
Pick whichever fits the consumer:
49+
50+
```ts
51+
// Callback flavor
52+
channel.onmessage = (data, isUtf8) => handle(data);
53+
54+
// RxJS flavor
55+
channel.message$.subscribe((data) => handle(data));
56+
```
57+
58+
Same applies to close: `onclose` and `close$` both fire once on disconnect.
59+
60+
61+
## `send()` vs `send$()`
62+
63+
- `send(data)` is **fire-and-forget**: writes immediately if open, returns
64+
the number of bytes now buffered. Returns `-1` if the channel is not
65+
ready --- useful as a quick liveness check.
66+
- `send$(data)` is **wait-then-send**: returns an Observable that defers
67+
until `open$` fires. Errors if the channel closes first.
68+
69+
```ts
70+
// Fire only if connected right now
71+
if (channel.isOpen()) channel.send(message);
72+
73+
// Queue until the channel opens (e.g. immediately after construction)
74+
channel.send$(message).subscribe();
75+
```
76+
77+
78+
## Close event
79+
80+
Both `close$` and `onclose` carry a `CloseEventBase`:
81+
82+
| Field | Description |
83+
|---|---|
84+
| `code` | Numeric close code (e.g. 1000 for normal closure) |
85+
| `reason` | Free-form reason text |
86+
| `wasClean` | Whether the close handshake completed |
87+
88+
`code` follows WebSocket conventions where applicable but every transport
89+
chooses its own scheme for non-WebSocket scenarios.

packages/channel/docs/text.md

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,57 @@
1-
## Communication channel
1+
## Channel
22

3-
A minimal abstraction for bidirectional communication channels. It wraps
4-
WebSockets, `fetch`, and other transports behind a single `PhysicalChannel`
5-
interface backed by RxJS observables. This is the transport layer beneath
6-
[rpc-calls](/libs/rpc-calls) and [rpc-client](/libs/rpc-client).
3+
`@jsonjoy.com/channel` is a thin abstraction for bidirectional byte streams.
4+
Every concrete transport --- WebSocket, `fetch`, in-memory test pipe ---
5+
implements the same `PhysicalChannel` interface, exposing incoming messages
6+
and lifecycle events as RxJS observables.
7+
8+
This is the transport layer underneath the Reactive RPC stack
9+
([`rpc-calls`](/libs/rpc-calls), [`rpc-client`](/libs/rpc-client)) but is
10+
useful on its own when you just need a uniform shape over WebSocket plus
11+
auto-reconnect.
712

813

914
## Installation
1015

1116
```
12-
npm install @jsonjoy.com/channel
17+
npm install @jsonjoy.com/channel rxjs
1318
```
1419

20+
`rxjs@7` is a peer dependency.
21+
22+
23+
## Surface
24+
25+
| Area | Surface |
26+
|---|---|
27+
| [PhysicalChannel](/libs/channel/physical-channel) | The interface and lifecycle |
28+
| [Transports](/libs/channel/transports) | `WebSocketChannel`, `FetchPhysicalChannel`, `Utf8Channel` |
29+
| [Persistent channel](/libs/channel/persistent-channel) | Auto-reconnect with configurable backoff |
30+
31+
32+
## Quick start
33+
34+
```ts
35+
import {WebSocketChannel} from '@jsonjoy.com/channel';
1536

16-
## Components
37+
const channel = new WebSocketChannel<Uint8Array>({
38+
newSocket: () => new WebSocket('wss://example.com/rx'),
39+
});
1740

18-
- **`PhysicalChannel`** — the interface every channel implements: `message$`,
19-
`error$`, `open$`, `close$`, `state$`, `send()`, `send$()`, `close()`, `buffer()`, `isOpen()`.
20-
- **`WebSocketChannel`** — wraps a native `WebSocket` into a `PhysicalChannel`.
21-
- **`FetchChannel`** — wraps a `fetch`-style request/response into a channel.
22-
- **`Utf8Channel`** — transparently converts binary messages to and from UTF-8.
23-
- **`PersistentChannel`** — auto-reconnecting wrapper with configurable backoff.
41+
channel.open$.subscribe(() => channel.send(new Uint8Array([1, 2, 3])));
42+
channel.message$.subscribe((data) => console.log('recv', data));
43+
channel.close$.subscribe(([, evt]) => console.log('closed', evt.code));
44+
```
45+
46+
For auto-reconnect, wrap it in [`PersistentPhysicalChannel`](/libs/channel/persistent-channel):
47+
48+
```ts
49+
import {PersistentPhysicalChannel} from '@jsonjoy.com/channel';
50+
51+
const persistent = new PersistentPhysicalChannel<Uint8Array>({
52+
newChannel: () => new WebSocketChannel({newSocket: () => new WebSocket(url)}),
53+
});
54+
55+
persistent.start();
56+
persistent.message$.subscribe((data) => handle(data));
57+
```
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
## Transports
2+
3+
Three concrete implementations ship with the package:
4+
5+
| Class | Wraps | When to use |
6+
|---|---|---|
7+
| `WebSocketChannel` | Browser/Node `WebSocket` | Bidirectional, persistent stream |
8+
| `FetchPhysicalChannel` | `fetch`-style RPC call | Request/response over HTTP |
9+
| `Utf8Channel` | Another channel | Force text payloads on a binary transport |
10+
11+
12+
## `WebSocketChannel`
13+
14+
Wraps any `WebSocket`-shaped object. The constructor takes a `newSocket`
15+
factory so the channel can recreate the socket on reconnect (when used with
16+
[`PersistentPhysicalChannel`](/libs/channel/persistent-channel)).
17+
18+
```ts
19+
import {WebSocketChannel} from '@jsonjoy.com/channel';
20+
21+
const channel = new WebSocketChannel<Uint8Array>({
22+
newSocket: () => new WebSocket('wss://example.com/rx', ['rpc.rx.binary.cbor']),
23+
});
24+
```
25+
26+
The `binaryType` is automatically set to `'arraybuffer'`, so incoming binary
27+
messages arrive as `Uint8Array`. Text frames arrive as `string` with the
28+
`isUtf8` flag on `onmessage` set to `true`.
29+
30+
`buffer()` returns the WebSocket's native `bufferedAmount`. `close(code,
31+
reason)` forwards directly to `ws.close(...)`.
32+
33+
34+
## `FetchPhysicalChannel`
35+
36+
Adapts a request/response function into the channel interface. Every
37+
`send()` triggers one `fetch`, and the response is emitted as a single
38+
incoming message.
39+
40+
```ts
41+
import {FetchPhysicalChannel} from '@jsonjoy.com/channel';
42+
43+
const channel = new FetchPhysicalChannel({
44+
fetch: async (data) => {
45+
const res = await fetch('https://example.com/rpc', {
46+
method: 'POST',
47+
body: data,
48+
});
49+
return new Uint8Array(await res.arrayBuffer());
50+
},
51+
});
52+
```
53+
54+
Because there is no persistent stream, the channel reports
55+
`state$ === OPEN` immediately. `buffer()` always returns `0`. Errors from
56+
`fetch` are pushed to `error$`.
57+
58+
59+
## `Utf8Channel`
60+
61+
A decorator. Wraps any underlying channel and converts payloads to/from
62+
UTF-8 strings on the way in and out. Use it when you want the rest of your
63+
code to deal in strings but the transport carries bytes.
64+
65+
```ts
66+
import {Utf8Channel, WebSocketChannel} from '@jsonjoy.com/channel';
67+
68+
const inner = new WebSocketChannel<Uint8Array>({newSocket: () => new WebSocket(url)});
69+
const channel = new Utf8Channel(inner);
70+
71+
channel.send('hello'); // encoded to UTF-8 bytes and sent through `inner`
72+
channel.message$.subscribe((str) => console.log(str)); // already decoded
73+
```
74+
75+
`Utf8Channel` shares the inner channel's `state$`, `open$`, `close$`, and
76+
`error$` streams.
77+
78+
79+
## Which to pick
80+
81+
| Need | Use |
82+
|---|---|
83+
| Streaming bidirectional connection | `WebSocketChannel` |
84+
| Single-shot HTTP request/response per call | `FetchPhysicalChannel` |
85+
| String API over a binary channel | `Utf8Channel` |
86+
| Survive disconnects | wrap any of the above in [`PersistentPhysicalChannel`](/libs/channel/persistent-channel) |

packages/collaborative-presence/src/str.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {JsonCrdtDataType} from 'json-joy/lib/json-crdt-patch/constants';
1+
import {NodeType} from './constants';
22
import * as id from './id';
33
import type {ITimestampStruct, StrApi, StrNode, Model} from 'json-joy/lib/json-crdt';
44
import type {PresenceIdShorthand, PresencePoint, RgaSelection, PresenceCursor} from './types';
@@ -49,7 +49,7 @@ export const toDto = (str: StrApi, selections: StrSelection[]): RgaSelection =>
4949
}
5050
cursors.push(cursor);
5151
}
52-
const selection: RgaSelection = ['', '', sid, clock.time, {}, JsonCrdtDataType.str, nodeId, cursors];
52+
const selection: RgaSelection = ['', '', sid, clock.time, {}, NodeType.str, nodeId, cursors];
5353
return selection;
5454
};
5555

@@ -109,7 +109,7 @@ const findOffset = (str: StrNode, tsId: ITimestampStruct, senderSid?: number, pr
109109
export const fromDto = (model: Model<any>, selection: RgaSelection): StrSelectionStrict[] => {
110110
const [_documentId, _uiLocationId, sid, time, _meta, type, nodeIdDto, cursors] = selection;
111111
const result: StrSelectionStrict[] = [];
112-
if (type !== JsonCrdtDataType.str) return result;
112+
if (type !== NodeType.str) return result;
113113
const nodeId = id.fromDto(sid, nodeIdDto);
114114
const str = model.index.get(nodeId) as StrNode | undefined;
115115
if (!str || str.name() !== 'str') return result;

0 commit comments

Comments
 (0)