tools/awps-tunnel/client/src/panels/sections/SubprotocolClientSection.tsx (453 lines of code) (raw):
import { useState, useRef, useEffect } from "react";
import { Checkbox, Dropdown, Option, Textarea, Input, Label, Tab, TabList, TabValue, Field, InfoLabel, MessageBar } from "@fluentui/react-components";
import { DefaultButton, DetailsList, DetailsListLayoutMode, SelectionMode } from "@fluentui/react";
import { ResizablePanel } from "../../components/ResizablePanel";
import { TrafficItem, TrafficItemViewModel } from "../../components/TrafficItem";
import { ConnectionStatus } from "../../models";
import { ClientPannelProps } from "../Playground";
import { SendMessageError, WebPubSubClient, WebPubSubJsonProtocol, WebPubSubJsonReliableProtocol } from "@azure/web-pubsub-client";
import { useDataContext } from "../../providers/DataContext";
import { Card } from "@fluentui/react-components";
interface SupportedAPI {
key: string;
name: string;
component: (props: APIComponentProps) => JSX.Element;
}
// following https://learn.microsoft.com/azure/azure-web-pubsub/reference-json-webpubsub-subprotocol
type JoinGroupAPIParameters = {
type: "joinGroup";
group: string;
ackId?: number;
};
type LeaveGroupAPIParameters = {
type: "leaveGroup";
group: string;
ackId?: number;
};
type SendToGroupAPIParameters = {
type: "sendToGroup";
group: string;
noEcho: boolean;
fireAndForget: boolean;
ackId?: number;
dataType: "text"; // TODO: support other dataType like "json" | "prototype" ?
data: string;
};
type SendEventAPIParameters = {
type: "event";
event: string;
fireAndForget: boolean;
ackId?: number;
dataType: "text"; // TODO: support other dataType like "json" | "prototype" ?
data: string;
};
type APIParameters = JoinGroupAPIParameters | LeaveGroupAPIParameters | SendToGroupAPIParameters | SendEventAPIParameters;
interface APIComponentProps {
onMessageChange: (params: APIParameters | undefined) => void;
onError: (message: string) => void;
}
function getDelimitedArray(value: string | undefined): string[] {
return value
? value
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "")
: [];
}
function InputField({
required,
label,
value,
placeholder,
multiline,
validationMessage,
onChange,
}: {
label: string;
value: string;
placeholder: string;
multiline?: boolean;
required?: boolean;
validationMessage?: string;
onChange: (ev: any, data: any) => void;
}) {
const [init, setInit] = useState(true);
useEffect(() => {
// disable validation check when the component is first mounted
if (value && init) {
setInit(false);
}
}, [value, init]);
return (
<Field
required={required}
label={label}
orientation="horizontal"
validationState={!init && required && !value ? "error" : "none"}
validationMessage={!init && required && !value ? validationMessage : ""}
>
{multiline ? <Textarea placeholder={placeholder} onChange={onChange} /> : <Input placeholder={placeholder} onChange={onChange} />}
</Field>
);
}
function CheckboxField({ tips, label, onChange }: { tips: string; label: string; onChange: (ev: any, data: any) => void }) {
return (
<Field
orientation="horizontal"
label={{
children: <InfoLabel info={tips}>{label}</InfoLabel>,
}}
>
<Checkbox onChange={onChange} />
</Field>
);
}
function JoinGroup(props: APIComponentProps) {
const [name, setName] = useState<string>("");
useEffect(() => {
if (name) {
props.onMessageChange({ type: "joinGroup", group: name });
} else {
props.onMessageChange(undefined);
}
}, [name, props]);
return (
<div className="d-flex flex-fill flex-column">
<InputField required label="Group" value={name} placeholder="Group Name" validationMessage="Group name is required." onChange={(ev, data) => setName(data.value)} />
</div>
);
}
function LeaveGroup(props: APIComponentProps) {
const [name, setName] = useState<string>("");
useEffect(() => {
if (name) {
props.onMessageChange({ type: "leaveGroup", group: name });
} else {
props.onMessageChange(undefined);
}
}, [name, props]);
return (
<div className="d-flex flex-fill flex-column">
<InputField required label="Group" value={name} placeholder="Group Name" validationMessage="Group name is required." onChange={(ev, data) => setName(data.value)} />
</div>
);
}
function SendToGroup(props: APIComponentProps) {
const [name, setName] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [noEcho, setNoEcho] = useState<boolean>(false);
const [fireAndForget, setFireAndForget] = useState<boolean>(false);
useEffect(() => {
if (name && message) {
// TODO: support other dataType like JSON?
props.onMessageChange({ type: "sendToGroup", group: name, data: message, dataType: "text", fireAndForget: fireAndForget, noEcho: noEcho });
} else {
props.onMessageChange(undefined);
}
}, [name, message, props, fireAndForget, noEcho]);
return (
<div className="d-flex flex-fill flex-column">
<InputField required label="Group" value={name} placeholder="Group Name" validationMessage="Group name is required." onChange={(ev, data) => setName(data.value)} />
<InputField required multiline label="Message" value={message} placeholder="Input the message to send" validationMessage="Message is required." onChange={(ev, data) => setMessage(data.value)} />
<CheckboxField tips="If checked, this message isn't echoed back to the same connection." label="NoEcho" onChange={(ev, data) => setNoEcho(data.checked === true)} />
<CheckboxField
tips="If checked, the message won't contains ackId and no AckMessage will be returned from the service."
label="FireAndForget"
onChange={(ev, data) => setFireAndForget(data.checked === true)}
/>
</div>
);
}
function SendEvent(props: APIComponentProps) {
const [name, setName] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [fireAndForget, setFireAndForget] = useState<boolean>(false);
useEffect(() => {
if (name && message) {
// order is a must
props.onMessageChange({
type: "event",
event: name,
data: message,
dataType: "text",
fireAndForget: fireAndForget,
});
} else {
props.onMessageChange(undefined);
}
}, [name, message, props, fireAndForget]);
return (
<div className="d-flex flex-fill flex-column">
<InputField required label="Event" value={name} placeholder="Event Name" validationMessage="Event name is required." onChange={(ev, data) => setName(data.value)} />
<InputField required multiline label="Message" value={message} placeholder="Input the message to send" validationMessage="Message is required." onChange={(ev, data) => setMessage(data.value)} />
<CheckboxField
tips="If checked, the message won't contains ackId and no AckMessage will be returned from the service."
label="FireAndForget"
onChange={(ev, data) => setFireAndForget(data.checked === true)}
/>
</div>
);
}
const supportedAPIs: SupportedAPI[] = [
{ key: "sendToGroup", name: "Send to Group", component: SendToGroup },
{ key: "sendEvent", name: "Send Event", component: SendEvent },
{ key: "joinGroup", name: "Join Group", component: JoinGroup },
{ key: "leaveGroup", name: "Leave Group", component: LeaveGroup },
];
function ConnectPane({ connected, onSendingMessage, sendError }: { connected: boolean; sendError: string; onSendingMessage: (message: APIParameters) => void }) {
const [params, setParams] = useState<APIParameters | undefined>(undefined);
const [message, setMessage] = useState("");
const [selectedAPI, setSelectedAPI] = useState<TabValue>(supportedAPIs[0].key);
const send = () => {
if (params) {
onSendingMessage(params);
}
};
return (
<div className="d-flex flex-column flex-fill overflow-auto">
<b>Supported APIs</b>
<div className="d-flex flex-row flex-fill overflow-auto">
<TabList
defaultSelectedValue={selectedAPI}
vertical
onTabSelect={(_, d) => {
setSelectedAPI(d.value);
}}
>
{supportedAPIs.map((api) => (
<Tab value={api.key} key={api.key}>
{api.name}
</Tab>
))}
</TabList>
<Card className="d-flex flex-column flex-fill">
{supportedAPIs.map((api) => {
if (api.key === selectedAPI) {
const Component = api.component;
return (
<div key={api.key} className="d-flex flex-fill">
<Component
onMessageChange={(message) => {
if (message) {
setParams(message);
setMessage(JSON.stringify(message, null, 2));
} else {
setMessage("");
}
}}
onError={(e) => {
setParams(undefined);
setMessage("");
}}
/>
</div>
);
} else return <></>;
})}
</Card>
</div>
<div className="d-flex flex-column flex-fill overflow-auto">
<b>Generated payload</b>
<textarea readOnly className="flex-fill" value={message} />
</div>
{sendError && <MessageBar intent="warning">{sendError}</MessageBar>}
<DefaultButton disabled={!connected || !message} text="Send" onClick={send}></DefaultButton>
</div>
);
}
export const SubprotocolClientSection = ({ onStatusChange, url }: ClientPannelProps) => {
const transferOptions = [
{ key: "json", text: "JSON", subprotocol: "json.webpubsub.azure.v1" },
{ key: "rjson", text: "Reliable JSON", subprotocol: "json.reliable.webpubsub.azure.v1" },
// { key: "binary", text: "Binary" }, // TODO: support binary
];
// url as the default one, adding group permissions need dataFetcher
const { dataFetcher } = useDataContext();
const [connected, setConnected] = useState<boolean>(false);
const [subprotocol, setSubprotocol] = useState<string | undefined>(transferOptions[0].subprotocol);
const [traffic, setTraffic] = useState<TrafficItemViewModel[]>([]);
const [error, setError] = useState("");
const [sendError, setSendError] = useState("");
const [userId, setUserId] = useState("");
const [joinLeaveGroups, setJoinLeaveGroups] = useState<string[]>([]);
const [publishGroups, setPublishGroups] = useState<string[]>([]);
const [initialGroups, setInitialGroups] = useState<string[]>([]);
const connectionRef = useRef<WebPubSubClient | null>(null);
const disconnect = () => {
onStatusChange(ConnectionStatus.Disconnected);
setConnected(false);
setTraffic([]);
setError("");
setSendError("");
setUserId("");
setJoinLeaveGroups([]);
setPublishGroups([]);
setInitialGroups([]);
connectionRef.current?.stop();
return true;
};
const connect = async () => {
// clear the state before start
setError("");
setSendError("");
setTraffic([]);
const connection = new WebPubSubClient(
{
getClientAccessUrl: async () => {
if (joinLeaveGroups.length === 0 && publishGroups.length === 0) {
return url; // url is updated in the data provider
}
const roles = joinLeaveGroups.map((s) => `webpubsub.joinLeaveGroup.${s}`).concat(publishGroups.map((s) => `webpubsub.sendToGroup.${s}`));
// elsewise, we need to add group permissions
return dataFetcher.invoke("getClientAccessUrl", userId, roles, initialGroups);
},
},
{
protocol: subprotocol === transferOptions[0].subprotocol ? WebPubSubJsonProtocol() : WebPubSubJsonReliableProtocol(),
},
);
// Registers a listener for the "server-message". The callback will be invoked when your application server sends message to the connectionID, to or broadcast to all connections.
connection.on("server-message", (e) => {
console.log(`Received message ${e.message.data}`);
// TODO: data could be ArrayBuffer or JSONTypes
setTraffic((t) => [TrafficItem(JSON.stringify(e.message.data)), ...t]);
});
// Registers a listener for the "group-message". The callback will be invoked when the client receives a message from the groups it has joined.
connection.on("group-message", (e) => {
setTraffic((t) => [TrafficItem(JSON.stringify(e.message)), ...t]);
});
try {
await connection.start();
setConnected(true);
onStatusChange(ConnectionStatus.Connected);
connectionRef.current = connection;
} catch (e) {
setConnected(false);
onStatusChange(ConnectionStatus.Disconnected);
console.error(e);
setTraffic([]);
setError(`Error establishing the connection ${(e as Error).message}`);
}
};
const send = async (message: APIParameters) => {
setSendError("");
if (!connectionRef.current) {
setSendError("Connection is not yet connected");
return;
}
try {
switch (message.type) {
case "joinGroup":
const joinGroup = message as JoinGroupAPIParameters;
await connectionRef.current.joinGroup(joinGroup.group, {
ackId: joinGroup.ackId,
});
break;
case "leaveGroup":
const leaveGroup = message as LeaveGroupAPIParameters;
await connectionRef.current.leaveGroup(leaveGroup.group, {
ackId: leaveGroup.ackId,
});
break;
case "sendToGroup":
const sendToGroup = message as SendToGroupAPIParameters;
await connectionRef.current.sendToGroup(sendToGroup.group, sendToGroup.data, sendToGroup.dataType, {
noEcho: sendToGroup.noEcho,
fireAndForget: sendToGroup.fireAndForget,
ackId: sendToGroup.ackId,
});
break;
case "event":
const sendEvent = message as SendEventAPIParameters;
await connectionRef.current.sendEvent(sendEvent.event, sendEvent.data, sendEvent.dataType, {
fireAndForget: sendEvent.fireAndForget,
ackId: sendEvent.ackId,
});
break;
}
setTraffic((e) => [TrafficItem(JSON.stringify(message), true), ...e]);
} catch (e) {
if (e instanceof SendMessageError) {
setSendError(`Error sending message: ${(e as SendMessageError).errorDetail?.message}`);
} else {
setSendError(`Error sending message: ${(e as Error).message}`);
}
}
};
const trafficPane = (
<div>
<DetailsList items={traffic} selectionMode={SelectionMode.none} layoutMode={DetailsListLayoutMode.justified}></DetailsList>
</div>
);
return (
<>
<div className="d-flex flex-row">
{!connected && (
<Dropdown
placeholder="Select the supported subprotocol"
onOptionSelect={(e, d) => {
setSubprotocol(d.optionValue);
}}
defaultValue={transferOptions[0].text}
>
{transferOptions.map((option) => (
<Option key={option.key} value={option.subprotocol}>
{option.text}
</Option>
))}
</Dropdown>
)}
<Input className="flex-fill" readOnly={true} placeholder="Loading" value={url}></Input>
</div>
{url && !connected && (
<div>
<b>Advanced Settings</b>
<div className="d-flex flex-column">
<div>
<b className="m-3">Connect with</b>
<Label htmlFor="userIdInput" style={{ paddingInline: "12px" }}>
User ID
</Label>
<Input id="userIdInput" placeholder="(Empty User ID)" onChange={(ev, data) => setUserId(data.value)} />
<Label htmlFor="initialGroupsInput" style={{ paddingInline: "12px" }}>
Groups
</Label>
<Input id="initialGroupsInput" placeholder="Use comma(,) to separate" onChange={(ev, data) => setInitialGroups(getDelimitedArray(data.value))} />
</div>
<div>
<b className="m-3">Permissions</b>
<Label htmlFor="joinLeaveGroupInput" style={{ paddingInline: "12px" }}>
Allow join or leave groups
</Label>
<Input id="joinLeaveGroupInput" placeholder="Use comma(,) to separate" onChange={(ev, data) => setJoinLeaveGroups(getDelimitedArray(data.value))} />
<Label htmlFor="publishGroupInput" style={{ paddingInline: "12px" }}>
Allow send to groups
</Label>
<Input id="publishGroupInput" placeholder="Use comma(,) to separate" onChange={(ev, data) => setPublishGroups(getDelimitedArray(data.value))} />
</div>
</div>
<DefaultButton disabled={!subprotocol} className="flex-right" onClick={connect}>
Connect
</DefaultButton>
</div>
)}
{url && connected && (
<div>
<b>Connected With</b>
<div>
<b className="m-3">
Subprotocol <code>{subprotocol}</code>
</b>
<b className="m-3">User ID:</b> <code>{userId ? userId : "(Anonymous)"}</code>
<b className="m-3">Initially joined groups: </b> <code>{initialGroups.length > 0 ? initialGroups.join(", ") : "(None)"}</code>
<b className="m-3">Allowed to join or leave groups: </b> <code>{joinLeaveGroups.length > 0 ? joinLeaveGroups.join(", ") : "(None)"}</code>
<b className="m-3">Allowed to send to groups: </b> <code>{publishGroups.length > 0 ? publishGroups.join(", ") : "(None)"}</code>
</div>
<div>
<DefaultButton className="flex-right" onClick={disconnect}>
Disconnect
</DefaultButton>
</div>
</div>
)}
{error && <b className="text-danger">{error}</b>}
<MessageBar>Press F12 to view the real network traffic flow</MessageBar>
{connected && <ResizablePanel className="flex-fill" left={<ConnectPane sendError={sendError} connected={connected} onSendingMessage={send}></ConnectPane>} right={trafficPane}></ResizablePanel>}
</>
);
};