contour-deck 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/remote/guest.d.ts +2 -2
- package/dist/remote/guest.js +8 -2
- package/dist/remote/labels.d.ts +4 -0
- package/dist/remote/labels.js +3 -1
- package/dist/remote/panel.d.ts +2 -0
- package/dist/remote/panel.js +6 -0
- package/dist/remote/phone.js +2 -1
- package/dist/remote/session.js +12 -7
- package/dist/remote/style.js +2 -1
- package/dist/remote/transport.d.ts +2 -1
- package/dist/remote/transport.js +13 -5
- package/docs/PROTOCOL.md +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,7 +186,7 @@ The laser dot eases towards its target and fades when released; with `prefers-re
|
|
|
186
186
|
#### The relay
|
|
187
187
|
|
|
188
188
|
The relay is a WebSocket forwarder, one room per talk, specified in [docs/PROTOCOL.md](docs/PROTOCOL.md): rooms, the pairing messages, close codes, and limits (2 KB frames, 60 messages a second, 4 guests).
|
|
189
|
-
`relay(url, { room, role, key })` and `broadcast(room)` are the two `Transport`s (`send`, `subscribe`, `watch`, `reconnect`, `close`, `status`); the relay one reconnects with backoff, except after `4003` (removed by the host)
|
|
189
|
+
`relay(url, { room, role, key })` and `broadcast(room)` are the two `Transport`s (`send`, `subscribe`, `watch`, `reconnect`, `close`, `status`); the relay one reconnects with backoff (up to 15 s between tries, 60 s after `4004`, no host), except after `4003` (removed by the host), `4009` (another tab of the session took the host seat), and, for a guest, `4029` (room full).
|
|
190
190
|
The demo takes a relay from the query string, the deployed one or one on this machine: `https://wayne930242.github.io/contour-deck/?relay=wss://contour-relay.wayne930242.workers.dev&view=deck`, or `?relay=ws://localhost:8787`; it ignores any other.
|
|
191
191
|
The phone must be able to open the page's URL, so a deck on `localhost` needs a tunnel or the LAN address for the phone part.
|
|
192
192
|
|
package/dist/remote/guest.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Transport } from "./transport.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* `connecting`: no answer from the room yet. `pairing`: showing a code, waiting for the presenter.
|
|
5
5
|
* `granted`: in control. `denied`, `revoked`: the presenter said no; the connection is closed until `retry()`.
|
|
6
|
-
* `offline`: the connection ended and is being retried
|
|
6
|
+
* `offline`: the connection ended and is being retried, except after 4029 (room full), where it waits for `retry()`.
|
|
7
7
|
*/
|
|
8
8
|
export type GuestPhase = "connecting" | "pairing" | "granted" | "denied" | "revoked" | "offline";
|
|
9
9
|
export interface Stroke {
|
|
@@ -25,7 +25,7 @@ export interface Guest {
|
|
|
25
25
|
/** Starts an ink stroke; its points go out in batches. */
|
|
26
26
|
stroke(): Stroke;
|
|
27
27
|
clearInk(): void;
|
|
28
|
-
/** After `denied` or `revoked
|
|
28
|
+
/** After `denied` or `revoked`, or offline after 4029 (room full): connects again and asks anew. */
|
|
29
29
|
retry(): void;
|
|
30
30
|
/** Calls `listener` after every change of phase, code, or state. */
|
|
31
31
|
subscribe(listener: () => void): () => void;
|
package/dist/remote/guest.js
CHANGED
|
@@ -202,10 +202,16 @@ export function createGuest({ room, open, trusted = false }) {
|
|
|
202
202
|
},
|
|
203
203
|
clearInk: () => send({ t: "ink-clear" }),
|
|
204
204
|
retry() {
|
|
205
|
-
if (closed
|
|
205
|
+
if (closed)
|
|
206
|
+
return;
|
|
207
|
+
const full = phase === "offline" && transport.code === 4029;
|
|
208
|
+
if (!full && phase !== "denied" && phase !== "revoked")
|
|
206
209
|
return;
|
|
207
210
|
phase = "connecting";
|
|
208
|
-
|
|
211
|
+
if (full)
|
|
212
|
+
transport.reconnect();
|
|
213
|
+
else
|
|
214
|
+
connect();
|
|
209
215
|
emit();
|
|
210
216
|
},
|
|
211
217
|
subscribe(listener) {
|
package/dist/remote/labels.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface RemoteLabels {
|
|
|
12
12
|
relayOpen: string;
|
|
13
13
|
relayClosed: string;
|
|
14
14
|
roomTaken: string;
|
|
15
|
+
/** Name of the bar button while another tab holds the room. */
|
|
16
|
+
roomTakenBar: string;
|
|
17
|
+
/** Takes the room back for this tab. */
|
|
18
|
+
useThisWindow: string;
|
|
15
19
|
request: string;
|
|
16
20
|
/** Name of the bar button while phones wait for an answer. */
|
|
17
21
|
waiting(count: number): string;
|
package/dist/remote/labels.js
CHANGED
|
@@ -10,6 +10,8 @@ export const defaultRemoteLabels = {
|
|
|
10
10
|
relayOpen: "中繼已連線",
|
|
11
11
|
relayClosed: "中繼離線,重試中",
|
|
12
12
|
roomTaken: "房間已被另一個視窗使用",
|
|
13
|
+
roomTakenBar: "遙控:房間已被另一個視窗使用",
|
|
14
|
+
useThisWindow: "改用這個視窗",
|
|
13
15
|
request: "有手機要求控制",
|
|
14
16
|
waiting: count => `遙控:${count} 支手機等待允許`,
|
|
15
17
|
code: "確認碼",
|
|
@@ -30,7 +32,7 @@ export const defaultRemoteLabels = {
|
|
|
30
32
|
revoked: "電腦收回了控制權。",
|
|
31
33
|
retry: "重新要求控制",
|
|
32
34
|
noHost: "找不到簡報:電腦還沒開啟遙控,或已經關閉。重試中…",
|
|
33
|
-
full: "
|
|
35
|
+
full: "已有太多裝置連上這場簡報。等有人離開後再試一次。",
|
|
34
36
|
badLink: "這個連結無法遙控,請重新掃描電腦上的 QR。",
|
|
35
37
|
notesOnly: "只看講稿",
|
|
36
38
|
leave: "離開",
|
package/dist/remote/panel.d.ts
CHANGED
package/dist/remote/panel.js
CHANGED
|
@@ -16,6 +16,10 @@ export function createPanel(options) {
|
|
|
16
16
|
const closeButton = button("close", "✕");
|
|
17
17
|
closeButton.setAttribute("aria-label", labels.close);
|
|
18
18
|
head.append(h("h2", "", labels.title), status, closeButton);
|
|
19
|
+
// Another tab with the same key holds the seat (4009); this tab takes it back only when asked.
|
|
20
|
+
const retake = el.appendChild(h("div", "ct-rp-row"));
|
|
21
|
+
retake.append(h("p", "", labels.roomTaken), button("retake", labels.useThisWindow, true));
|
|
22
|
+
retake.hidden = true;
|
|
19
23
|
/** Requests and the paired phone: the part that changes. */
|
|
20
24
|
const live = el.appendChild(h("div", "ct-rp-live", "", { "aria-live": "polite" }));
|
|
21
25
|
live.style.display = "contents";
|
|
@@ -52,6 +56,7 @@ export function createPanel(options) {
|
|
|
52
56
|
allow: options.allow,
|
|
53
57
|
deny: options.deny,
|
|
54
58
|
revoke: options.revoke,
|
|
59
|
+
retake: options.retake,
|
|
55
60
|
presenter: options.presenter,
|
|
56
61
|
reset: options.resetTimer,
|
|
57
62
|
end: options.end,
|
|
@@ -101,6 +106,7 @@ export function createPanel(options) {
|
|
|
101
106
|
: view.relay === "closed"
|
|
102
107
|
? labels.relayClosed
|
|
103
108
|
: labels.relayConnecting;
|
|
109
|
+
retake.hidden = !(view.relay === "closed" && view.code === 4009);
|
|
104
110
|
status.textContent = relayText;
|
|
105
111
|
status.hidden = !relayText;
|
|
106
112
|
status.dataset.st = view.relay ?? "";
|
package/dist/remote/phone.js
CHANGED
|
@@ -139,7 +139,8 @@ export function remoteView(root, options = {}) {
|
|
|
139
139
|
const { phase, closeCode } = guest;
|
|
140
140
|
cardCode.hidden = phase !== "pairing";
|
|
141
141
|
cardCode.textContent = guest.code;
|
|
142
|
-
|
|
142
|
+
// A full room is not retried by itself: each try counts against the relay's limit for this network.
|
|
143
|
+
retry.hidden = phase !== "denied" && phase !== "revoked" && !(phase === "offline" && closeCode === 4029);
|
|
143
144
|
cardBadge.textContent = phase === "pairing" ? labels.code : phase === "offline" ? labels.offline : labels.title;
|
|
144
145
|
cardText.textContent =
|
|
145
146
|
phase === "pairing"
|
package/dist/remote/session.js
CHANGED
|
@@ -182,6 +182,7 @@ function start(deck, root, options, { app, stage, viewport }, { key, room, befor
|
|
|
182
182
|
allow: id => pairing.allow(id),
|
|
183
183
|
deny: id => pairing.deny(id),
|
|
184
184
|
revoke: () => pairing.revoke(),
|
|
185
|
+
retake: () => wan?.reconnect(),
|
|
185
186
|
presenter: () => void window.open(pageLink("presenter", room), "contour-deck-presenter", "popup,width=1180,height=760"),
|
|
186
187
|
resetTimer() {
|
|
187
188
|
clock.reset();
|
|
@@ -204,15 +205,19 @@ function start(deck, root, options, { app, stage, viewport }, { key, room, befor
|
|
|
204
205
|
paired: pairing.controller ? "online" : pairing.token ? "offline" : "no",
|
|
205
206
|
elapsed: clock.read(Date.now()).elapsed,
|
|
206
207
|
});
|
|
207
|
-
// With the panel closed, a waiting phone shows as a count on the bar button
|
|
208
|
-
|
|
208
|
+
// With the panel closed, a waiting phone shows as a count on the bar button, and a seat lost to another tab
|
|
209
|
+
// as a mark on it; nothing opens by itself.
|
|
210
|
+
const taken = wan?.status === "closed" && wan.code === 4009;
|
|
211
|
+
barButton?.toggleAttribute("data-ct-taken", taken);
|
|
212
|
+
if (requests.length)
|
|
209
213
|
barButton?.setAttribute("data-ct-count", String(requests.length));
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
214
|
+
else
|
|
213
215
|
barButton?.removeAttribute("data-ct-count");
|
|
216
|
+
const name = taken ? labels.roomTakenBar : requests.length ? labels.waiting(requests.length) : null;
|
|
217
|
+
if (name)
|
|
218
|
+
barButton?.setAttribute("aria-label", name);
|
|
219
|
+
else
|
|
214
220
|
barButton?.removeAttribute("aria-label");
|
|
215
|
-
}
|
|
216
221
|
barButton?.setAttribute("aria-pressed", String(panel.open));
|
|
217
222
|
}
|
|
218
223
|
const stops = [
|
|
@@ -275,7 +280,7 @@ function start(deck, root, options, { app, stage, viewport }, { key, room, befor
|
|
|
275
280
|
wan?.close();
|
|
276
281
|
overlay.destroy();
|
|
277
282
|
panel.destroy();
|
|
278
|
-
for (const name of ["data-ct-count", "aria-label", "aria-pressed"])
|
|
283
|
+
for (const name of ["data-ct-count", "data-ct-taken", "aria-label", "aria-pressed"])
|
|
279
284
|
barButton?.removeAttribute(name);
|
|
280
285
|
hosts.delete(deck);
|
|
281
286
|
},
|
package/dist/remote/style.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* and go first in <head>, so a host's own rules win a tie.
|
|
5
5
|
*/
|
|
6
6
|
const css = `
|
|
7
|
-
.ct-btn[data-ct-act="remote"][data-ct-count]::after { content: attr(data-ct-count); content: attr(data-ct-count) / ""; margin-left: .4em; padding: .1em .4em; background: var(--ct-accent); color: var(--ct-accent-fg); }
|
|
7
|
+
.ct-btn[data-ct-act="remote"]:is([data-ct-count], [data-ct-taken])::after { content: attr(data-ct-count); content: attr(data-ct-count) / ""; margin-left: .4em; padding: .1em .4em; background: var(--ct-accent); color: var(--ct-accent-fg); }
|
|
8
|
+
.ct-btn[data-ct-act="remote"][data-ct-taken]::after { content: "!"; content: "!" / ""; }
|
|
8
9
|
|
|
9
10
|
/* ---- over the stage: laser dot and ink ---- */
|
|
10
11
|
.ct-ink { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; color: var(--ct-accent); }
|
|
@@ -30,6 +30,7 @@ export interface RelayOptions {
|
|
|
30
30
|
export declare function relayUrl(base: string, { room, role, key }: RelayOptions): string;
|
|
31
31
|
/**
|
|
32
32
|
* A WebSocket to the relay. A connection that ends is opened again with exponential backoff
|
|
33
|
-
* (0.5 s doubling to 15 s, with jitter), and at once when the page comes back or the network
|
|
33
|
+
* (0.5 s doubling to 15 s, 60 s after 4004, with jitter), and at once when the page comes back or the network
|
|
34
|
+
* returns. After a final close it waits for `reconnect()`.
|
|
34
35
|
*/
|
|
35
36
|
export declare function relay(base: string, options: RelayOptions): Transport;
|
package/dist/remote/transport.js
CHANGED
|
@@ -46,18 +46,25 @@ export function broadcast(room) {
|
|
|
46
46
|
}
|
|
47
47
|
const FIRST_RETRY = 500;
|
|
48
48
|
const LAST_RETRY = 15_000;
|
|
49
|
+
/**
|
|
50
|
+
* The longest wait after `4004` (no host): a phone left open after the talk tries about once a minute, so the
|
|
51
|
+
* phones of one venue network do not spend the relay's per-address budget, which the presenter's phone shares.
|
|
52
|
+
*/
|
|
53
|
+
const LAST_RETRY_NO_HOST = 60_000;
|
|
49
54
|
/** `https://relay.example` or `wss://relay.example` to the room endpoint. */
|
|
50
55
|
export function relayUrl(base, { room, role, key }) {
|
|
51
56
|
return `${base.replace(/^http/, "ws").replace(/\/+$/, "")}/v1/room/${room}?role=${role}${key ? `&key=${key}` : ""}`;
|
|
52
57
|
}
|
|
53
58
|
/**
|
|
54
59
|
* Closes after which the connection is not opened again by itself: 4009, a newer connection with the same key
|
|
55
|
-
* took the host seat (the same session in another tab); 4003, the host removed this guest
|
|
60
|
+
* took the host seat (the same session in another tab); 4003, the host removed this guest; 4029, a guest found
|
|
61
|
+
* the room full (the audience behind one address would otherwise keep spending its request budget).
|
|
56
62
|
*/
|
|
57
|
-
const FINAL = [4003, 4009];
|
|
63
|
+
const FINAL = { host: [4003, 4009], guest: [4003, 4009, 4029] };
|
|
58
64
|
/**
|
|
59
65
|
* A WebSocket to the relay. A connection that ends is opened again with exponential backoff
|
|
60
|
-
* (0.5 s doubling to 15 s, with jitter), and at once when the page comes back or the network
|
|
66
|
+
* (0.5 s doubling to 15 s, 60 s after 4004, with jitter), and at once when the page comes back or the network
|
|
67
|
+
* returns. After a final close it waits for `reconnect()`.
|
|
61
68
|
*/
|
|
62
69
|
export function relay(base, options) {
|
|
63
70
|
const messages = emitter();
|
|
@@ -108,10 +115,11 @@ export function relay(base, options) {
|
|
|
108
115
|
if (ended)
|
|
109
116
|
return;
|
|
110
117
|
set("closed");
|
|
111
|
-
parked = FINAL.includes(code);
|
|
118
|
+
parked = FINAL[options.role].includes(code);
|
|
112
119
|
if (parked)
|
|
113
120
|
return;
|
|
114
|
-
const
|
|
121
|
+
const last = code === 4004 ? LAST_RETRY_NO_HOST : LAST_RETRY;
|
|
122
|
+
const delay = Math.min(last, FIRST_RETRY * 2 ** attempt++) * (0.5 + Math.random() / 2);
|
|
115
123
|
timer = window.setTimeout(open, delay);
|
|
116
124
|
};
|
|
117
125
|
}
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -37,7 +37,8 @@ One Durable Object per room (`idFromName(room)`).
|
|
|
37
37
|
| no message in the room for 2 hours | closes everyone with `4000` |
|
|
38
38
|
| a socket exceeds 60 messages/s (burst 120) | extra messages are dropped; above 10 s of sustained excess, closes with `1008` |
|
|
39
39
|
| request `Origin` not in `ALLOWED_ORIGINS` | HTTP 403 before the upgrade |
|
|
40
|
-
| more than 60 requests to room paths a minute from one client address | HTTP 429 before anything else is checked |
|
|
40
|
+
| more than 60 requests to room paths a minute from one client address, not counting host requests with the room's key | HTTP 429 before anything else is checked |
|
|
41
|
+
| more than 30 host requests with the room's key a minute from one client address | HTTP 429 before anything else is checked |
|
|
41
42
|
|
|
42
43
|
`ALLOWED_ORIGINS` is a comma-separated Worker variable (e.g. `https://www.wayneh.tw,https://wayne930242.github.io,http://localhost:4321`). Guests never reach other guests.
|
|
43
44
|
|
|
@@ -92,8 +93,8 @@ The presenter window on the same computer uses `BroadcastChannel("contour-deck:<
|
|
|
92
93
|
| --- | --- |
|
|
93
94
|
| `4000` | room closed (host left or idle, or a new host took the seat) |
|
|
94
95
|
| `4003` | removed by the host (after `deny` or `revoke`); the client does not reconnect by itself |
|
|
95
|
-
| `4004` | no host in this room |
|
|
96
|
+
| `4004` | no host in this room; the client retries with a longer backoff (up to 60 s between tries) |
|
|
96
97
|
| `4009` | host replaced: a newer connection with the same key holds the host seat; the client does not reconnect by itself |
|
|
97
|
-
| `4029` | room full |
|
|
98
|
+
| `4029` | room full; the client does not reconnect by itself (each retry would count against the address's limit) |
|
|
98
99
|
| `1008` | rate limit |
|
|
99
100
|
| `1009` | message too large |
|