Initial Commit
This commit is contained in:
1
apps/testbed/src/client/pages/network-webrtc/index.ts
Normal file
1
apps/testbed/src/client/pages/network-webrtc/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NetworkWebRtcPage } from "./network-webrtc-page.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# NetworkWebRtcPage
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->## Purpose
|
||||
|
||||
The `NetworkWebRtcPage` demonstrates peer-to-peer WebRTC data channel communication between two browser tabs. It uses the Hono server's `/api/signalling` routes as a simple SDP exchange intermediary. One tab acts as the host (creates offer, initiates data channel), the other as the peer (polls for offer, creates answer). Once connected, both sides can exchange arbitrary text messages over a reliable ordered data channel.
|
||||
|
||||
## Features
|
||||
|
||||
- Role-based behaviour (`?role=host` or `?role=peer`) driven from URL search params
|
||||
- Session ID (`?session=<id>`) used to namespace the SDP exchange
|
||||
- RTCPeerConnection with public STUN server (`stun:stun.l.google.com:19302`)
|
||||
- Host creates offer via `pc.createOffer()` and posts to `/api/signalling/offer`
|
||||
- Peer polls `/api/signalling/offer`, creates answer, posts to `/api/signalling/answer`
|
||||
- Host polls `/api/signalling/answer` to complete the handshake
|
||||
- `data-status` attribute on status indicator reflects connection state for E2E tests
|
||||
- All required `data-testid` attributes for Playwright E2E testing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { NetworkWebRtcPage } from "./network-webrtc-page.js";
|
||||
|
||||
// Open two browser tabs:
|
||||
// Tab 1: http://localhost:3200/network-webrtc?role=host&session=abc123
|
||||
// Tab 2: http://localhost:3200/network-webrtc?role=peer&session=abc123
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
In multi-session Playwright tests, the host and peer pages are opened in separate browser contexts:
|
||||
|
||||
```ts
|
||||
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
|
||||
|
||||
const session = await createMultiSession({
|
||||
browser,
|
||||
count: 2,
|
||||
baseUrl: "http://localhost:3200/network-webrtc?session=test-123",
|
||||
roles: ["host", "peer"],
|
||||
});
|
||||
// session.pages[0] is host, session.pages[1] is peer
|
||||
await expect(session.pages[0].locator("[data-status='connected']")).toBeVisible();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `NetworkWebRtcPage(): React.JSX.Element`
|
||||
|
||||
No props. Role and session ID are read from `window.location.search`.
|
||||
|
||||
**data-testid attributes:**
|
||||
|
||||
- `page-title` — the `<h1>` heading
|
||||
- `demo-card` — wraps the status, message input, and output
|
||||
- `message-input` — text input for sending messages
|
||||
- `send-button` — sends the current input over the data channel
|
||||
- `output` — displays sent and received messages
|
||||
- `console-panel` — bottom console section with usage hints
|
||||
- `data-status` on status paragraph — one of `idle | connecting | connected | failed`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The host creates a data channel before creating the offer, ensuring the peer receives the data channel via the `ondatachannel` event. Both sides poll the signalling server at 1-second intervals with `setInterval`/`setTimeout`; polling is cleared on cleanup via the `useEffect` return function. The peer retries up to 10 times waiting for the host's offer. The signalling server stores SDPs in a plain in-memory `Map` — it is not persistent across server restarts and is suitable for demos only.
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { NetworkWebRtcPage } from "./network-webrtc-page.js";
|
||||
|
||||
const meta: Meta<typeof NetworkWebRtcPage> = {
|
||||
component: NetworkWebRtcPage,
|
||||
title: "Pages/NetworkWebRTC",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NetworkWebRtcPage>;
|
||||
|
||||
// Note: WebRTC won't connect in Storybook (no signalling server).
|
||||
// These stories show static UI states.
|
||||
|
||||
export const HostRole: Story = {};
|
||||
|
||||
export const PeerRole: Story = {
|
||||
parameters: {
|
||||
// Storybook can't replicate the URL param, so this is informational only
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Role = "host" | "peer";
|
||||
type ConnStatus = "idle" | "connecting" | "connected" | "failed";
|
||||
|
||||
const ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }];
|
||||
|
||||
export function NetworkWebRtcPage(): React.JSX.Element {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const role = (params.get("role") ?? "host") as Role;
|
||||
const sessionId = params.get("session") ?? "default";
|
||||
|
||||
const [connStatus, setConnStatus] = useState<ConnStatus>("idle");
|
||||
const [messages, setMessages] = useState<string[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const channelRef = useRef<RTCDataChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
pcRef.current = pc;
|
||||
setConnStatus("connecting");
|
||||
|
||||
if (role === "host") {
|
||||
const channel = pc.createDataChannel("chat");
|
||||
channelRef.current = channel;
|
||||
channel.onopen = () => setConnStatus("connected");
|
||||
channel.onclose = () => setConnStatus("idle");
|
||||
channel.onmessage = (e) =>
|
||||
setMessages((prev) => [...prev, `peer: ${String(e.data)}`]);
|
||||
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer))
|
||||
.then(() =>
|
||||
fetch("/api/signalling/offer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId, sdp: pc.localDescription?.sdp }),
|
||||
}),
|
||||
)
|
||||
.catch(() => setConnStatus("failed"));
|
||||
|
||||
// Poll for answer
|
||||
const pollAnswer = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/signalling/answer?sessionId=${sessionId}`);
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { sdp: string };
|
||||
if (data.sdp) {
|
||||
clearInterval(pollAnswer);
|
||||
await pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: "answer", sdp: data.sdp }),
|
||||
);
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollAnswer);
|
||||
pc.close();
|
||||
};
|
||||
} else {
|
||||
// peer role — fetch offer
|
||||
const initPeer = async () => {
|
||||
try {
|
||||
let offerResp;
|
||||
// Poll for offer
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const r = await fetch(`/api/signalling/offer?sessionId=${sessionId}`);
|
||||
if (r.ok) { offerResp = await r.json() as { sdp: string }; break; }
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
}
|
||||
if (!offerResp) { setConnStatus("failed"); return; }
|
||||
|
||||
await pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: "offer", sdp: offerResp.sdp }),
|
||||
);
|
||||
|
||||
pc.ondatachannel = (e) => {
|
||||
channelRef.current = e.channel;
|
||||
e.channel.onopen = () => setConnStatus("connected");
|
||||
e.channel.onclose = () => setConnStatus("idle");
|
||||
e.channel.onmessage = (ev) =>
|
||||
setMessages((prev) => [...prev, `host: ${String(ev.data)}`]);
|
||||
};
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await fetch("/api/signalling/answer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId, sdp: pc.localDescription?.sdp }),
|
||||
});
|
||||
} catch {
|
||||
setConnStatus("failed");
|
||||
}
|
||||
};
|
||||
initPeer();
|
||||
return () => { pc.close(); };
|
||||
}
|
||||
}, []);
|
||||
|
||||
function sendMessage() {
|
||||
if (!channelRef.current || channelRef.current.readyState !== "open") return;
|
||||
channelRef.current.send(input);
|
||||
setMessages((prev) => [...prev, `me: ${input}`]);
|
||||
setInput("");
|
||||
}
|
||||
|
||||
const statusColors: Record<ConnStatus, string> = {
|
||||
idle: "text-gray-400",
|
||||
connecting: "text-yellow-400",
|
||||
connected: "text-green-400",
|
||||
failed: "text-red-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto p-8">
|
||||
<h1 data-testid="page-title" className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
|
||||
Network — WebRTC
|
||||
</h1>
|
||||
<div
|
||||
data-testid="demo-card"
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6"
|
||||
>
|
||||
<p
|
||||
data-status={connStatus}
|
||||
className={`text-lg font-semibold mb-2 ${statusColors[connStatus]}`}
|
||||
>
|
||||
{connStatus.charAt(0).toUpperCase() + connStatus.slice(1)} — {role}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Session: <code className="font-mono">{sessionId}</code>
|
||||
</p>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
data-testid="message-input"
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") sendMessage(); }}
|
||||
disabled={connStatus !== "connected"}
|
||||
placeholder="Message to send"
|
||||
className="rounded border border-gray-300 dark:border-gray-600 px-3 py-2 flex-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
data-testid="send-button"
|
||||
type="button"
|
||||
onClick={sendMessage}
|
||||
disabled={connStatus !== "connected"}
|
||||
className="rounded-lg bg-blue-600 text-white px-4 py-2 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
data-testid="output"
|
||||
className="font-mono text-sm bg-gray-100 dark:bg-gray-900 rounded p-3 min-h-[4rem] space-y-1"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400">
|
||||
{connStatus === "connecting"
|
||||
? `Waiting for ${role === "host" ? "peer to join" : "host to create offer"}…`
|
||||
: connStatus === "connected"
|
||||
? "Connected! Send a message."
|
||||
: "Idle."}
|
||||
</p>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<p key={i} className="text-gray-700 dark:text-gray-300">{m}</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="console-panel"
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 p-4"
|
||||
>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-2">Console</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Signalling via <code className="font-mono">/api/signalling</code>. Use{" "}
|
||||
<code className="font-mono">?role=host</code> and{" "}
|
||||
<code className="font-mono">?role=peer&session=X</code> in two tabs.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user