Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
branches: [master]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
33 changes: 33 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Deploy

on:
push:
branches: [master]
workflow_dispatch:

concurrency:
group: deploy
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with:
# static/inject/controller is the RemoteSlide-Controller submodule and
# gets uploaded as part of the static assets.
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
.wrangler/
dist/
log/
.dev.vars
*.log
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
119 changes: 119 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Remote Slide wire protocol

The website, the injected page controller (bookmarklet or Chrome extension)
and the server talk over a single WebSocket per client. Every frame is a
JSON object with an `event` name; all other keys are the payload.

## Joining a session

```
GET /api/session
```

Creates a session, or continues the one named by the `rs-session-id` cookie.
Returns `{ "session": "<10 alphanumeric chars>", "qr": "data:image/svg+xml;base64,..." }`.
The QR code encodes `https://remote-sli.de/<session>`, the remote page.

```
GET /ws/<session>?as=<role>[&injector=<name>] (WebSocket upgrade)
```

| role | who |
| ---------- | ------------------------------------------------------- |
| `observer` | the browser tab showing the QR code (remote-sli.de/) |
| `host` | the presentation page, with the controller injected |
| `remote` | a phone on remote-sli.de/&lt;session&gt; |

A session has at most one host and one observer; a newer connection replaces
the older one, which is closed with code `4001`. Any number of remotes can
join. `injector` is free text describing how the host got injected
(`bookmark`, `extension_chrome`) and is shown in the connection info.

The first frame the server sends is always `init`:

```json
{ "event": "init", "state": "success", "youAre": "remote", "yourId": 2, "info": { ... } }
{ "event": "init", "state": "not_found" }
```

`not_found` is followed by close code `4004`. Clients must not reconnect on
close codes `4000`–`4999`; those are deliberate. Any other close is a lost
connection and should be retried (`rs-socket.js` does this with backoff).

## Connection info

```json
{
"observer": { "id": 0 } | false,
"host": { "id": 1, "injector": "bookmark" } | false,
"remotes": [ { "id": 2, "settings": { ... } } ]
}
```

Included in `init`, in every `info` frame, and returned on request:

| client sends | server answers |
| ----------------------------------------- | ----------------------------------------------- |
| `{ "event": "get", "what": "connectionInfo" }` | `{ "event": "connectionInfo", "info": {...} }` |

## Presence

Whenever a host or remote joins or leaves, the other parties get:

```json
{ "event": "info", "type": "client_connected", "clientType": "host", "who": "host", "info": { ... } }
{ "event": "info", "type": "client_disconnected", "clientType": "remote", "who": 2, "info": { ... } }
```

Host events go to the observer and all remotes; remote events go to the
observer, the host and the other remotes. Observers join and leave silently.

## Control

Only remotes may send it. It is delivered to the host (which synthesises the
key press) and echoed to every remote (used for haptic feedback).

```json
{ "event": "control", "keyCode": 39, "keys": { "shift": true } }
```

`keys` may contain `ctrl`, `shift`, `alt`; anything else is dropped. The
relayed frame carries an extra `from` (the sender's client id).

## Relayed events

Any event name that is not reserved (`init`, `info`, `connectionInfo`,
`err`, `control`, `latency`, `get`, `connect`, `disconnect`) is relayed
untouched, with `from` (client id) and `fromType` (`remote` | `host`) added:

* from a remote, to the host
* from the host, to all remotes
* observers cannot relay

The clients currently use `settings`, `slideInfo`, `screenshot`,
`overlayMessage`, `calibrationDot` and `deviceOrientation`. A remote's
`settings` are remembered by the server and included in the connection info,
so a host that connects later still learns the laser colour and username.

## Heartbeat and latency

Clients send the exact string `{"event":"latency"}` every two seconds and get
`{"event":"latency"}` back. The reply is produced by the edge without waking
the session's Durable Object. A client that stays silent for 45 seconds is
closed with code `4008`. (A `latency` frame with additional fields is answered
by the object itself with `{ "event": "latency", "t": <server time> }`.)

## Errors

```json
{ "event": "err", "code": 400, "msg": "Missing keyCode" }
```

Codes follow HTTP: `400` malformed, `403` not allowed for this role,
`413` too large.

## Lifecycle

A session lives in a Durable Object and expires one hour after the last
activity (an `/api/session` call, or a connection opening or closing). It
never expires while someone is connected.
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# remote-sli.de

The website and signalling server behind [Remote Slide](https://remote-sli.de):
control a presentation from your phone, with a laser pointer, slide previews
and no app to install.

It runs as a [Cloudflare Worker](https://developers.cloudflare.com/workers/).
The static site (AngularJS, served from `static/`) is uploaded as Workers
static assets; each presentation session is a
[Durable Object](https://developers.cloudflare.com/durable-objects/) that
holds the host, the remotes and the observer as hibernatable WebSockets and
relays messages between them. See [PROTOCOL.md](PROTOCOL.md) for the wire
format.

## Layout

| path | what |
| -------------------------- | ------------------------------------------------------------- |
| `src/index.ts` | Worker entry: routing, `/api/session`, `/ws/:session`, assets |
| `src/session-room.ts` | `SessionRoom` Durable Object, one per session |
| `src/protocol.ts` | shared constants and validation |
| `static/` | the website; `static/index.html` is the SPA shell |
| `static/inject/` | bookmarklet loader (`bookmark.js`, `injector.js`) |
| `static/inject/controller` | git submodule: [RemoteSlide-Controller](https://github.com/RemoteSlide/RemoteSlide-Controller), the script injected into presentation pages, plus `rs-socket.js`, the WebSocket client used everywhere |
| `test/` | Vitest suite running inside `workerd` |

## Development

```sh
git clone --recurse-submodules https://github.com/RemoteSlide/remote-sli.de
npm ci
npm run dev # http://localhost:8787
npm test
npm run typecheck
```

`npm run dev` serves the site and the API locally. Sessions, QR codes and
WebSockets all work against localhost; the QR code and the bookmarklet point
at `CANONICAL_ORIGIN` from `wrangler.jsonc` when that variable is set, so
override it (or remove it) in a local environment if you want to scan codes
against a dev server.

The `static/inject/controller` submodule has to be checked out (`git submodule
update --init`) for the bookmarklet and the website's socket client to work.

## Deployment

```sh
npm run deploy
```

`.github/workflows/deploy.yml` does the same on every push to `master`,
using the `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` repository
secrets (an API token with the *Edit Cloudflare Workers* template is enough).
The first deploy creates the `SessionRoom` Durable Object namespace from the
migration in `wrangler.jsonc`.

Point the `remote-sli.de` zone at the Worker with a custom domain or route.
Requests to `www.remote-sli.de` are redirected to the apex by the Worker
(`REDIRECT_HOSTS`).

## Companion repositories

* [RemoteSlide-Controller](https://github.com/RemoteSlide/RemoteSlide-Controller) - the page controller and `rs-socket.js`
* [RemoteSlide-Chrome](https://github.com/RemoteSlide/RemoteSlide-Chrome) - the Chrome extension (Manifest V3)

## License

[MIT](LICENSE)
Loading
Loading