Source on GitHub
Definition: shared/definitions/fill-together.json
shared/client/fill-together.js · sjs:fill-together.client
View on GitHub
import { Model } from "survey-core";
import { CollaborationPlugin } from "survey-core/collaboration"; // v3.1.1+
// One plugin on the model: it emits messages and applies incoming ones, and draws the
// participant bar, focus rings and cursors itself. The page only moves JSON frames.
// The relay runs as its own process, so its URL comes from the page config (RELAY_URL).
const ws = new WebSocket(relayUrl("fill", `/ws/rooms/${recordId}?name=${encodeURIComponent(userName)}`));
let collab;
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "init" && !collab) {
const survey = new Model(msg.seed); // the server hands out the definition
collab = new CollaborationPlugin(survey, { getInviteLink: inviteLink });
collab.onEvent.add((_, { message }) => ws.send(JSON.stringify(message))); // out: value, presence
survey.onComplete.add(postResponse);
mountSurvey(survey);
}
collab?.apply(msg); // in: init, value, peer, peer-left, as they arrive
});
app/Relay/FillRelay.php · sjs:fill-together.server
· run php artisan relay:fill (port 8081)
View on GitHub
// The relay, run as its own process: php artisan relay:fill (PHP-FPM can't hold connections).
// No SurveyJS here: the definition and the answers are opaque JSON.
private array $rooms = []; // room key => Room { seed, values, clients }
public function onConnect(TcpConnection $c, Request $request): void
{
$user = $this->user($request); // may this user open this record?
$recordId = $this->recordId($request);
$roomKey = $recordId === null ? null : RoomKey::fromHandshake($request, $recordId);
if (! $user || $roomKey === null) {
$c->close();
return;
}
$room = $this->rooms[$roomKey] ??= new Room(...$this->seed($recordId)); // the definition + the saved answers
$room->cancelDrop(); // back within 2 s: keep the room
$c->context->roomKey = $roomKey;
$c->context->id = bin2hex(random_bytes(8));
$c->context->name = self::name($request->get('name'), $user->name);
$c->context->slot = $room->freeSlot();
$c->context->state = null;
$room->clients[$c->id] = $c;
$this->send($c, ['type' => 'init', 'clientId' => $c->context->id, 'name' => $c->context->name,
'colorIndex' => Room::colorIndex($c->context->slot), 'seed' => $room->seed,
'values' => $room->snapshot(), 'peers' => $room->roster($c)]); // init first
}
public function onMessage(TcpConnection $from, string $raw): void
{
$msg = json_decode($raw); // objects stay objects: {} is relayed as {}
$room = $this->rooms[$from->context->roomKey ?? ''] ?? null;
if (! $room || ! $msg instanceof stdClass) {
return;
}
if (($msg->type ?? '') === 'value' && is_string($msg->key ?? null)) { // store, then fan out
$room->values[$msg->key] = $msg->value ?? null;
$room->broadcast($from, ['type' => 'value', 'from' => $from->context->id, 'key' => $msg->key, 'value' => $msg->value ?? null]);
} elseif (($msg->type ?? '') === 'presence' && isset($msg->state) && strlen($raw) <= 4096) { // stamp identity, relay
$retain = ($msg->retain ?? true) !== false;
if ($retain) {
$from->context->state = $msg->state;
}
$room->broadcast($from, ['type' => 'peer', 'retain' => $retain, 'peer' => Room::peer($from, $msg->state)], droppable: ! $retain);
} // unknown types: ignore
}
public function onClose(TcpConnection $c): void
{
$room = $this->rooms[$c->context->roomKey ?? ''] ?? null;
if (! $room) {
return;
}
unset($room->clients[$c->id]);
$room->broadcast($c, ['type' => 'peer-left', 'clientId' => $c->context->id]);
if (count($room->clients) === 0) { // drop the room 2 s after the last person leaves
$room->dropTimer = Timer::add(2, fn () => count($room->clients) === 0 && $this->drop($c->context->roomKey), [], false);
}
}