I.8
Fill one form together
You need this: When several people complete one record: a dispatcher and a technician on a job sheet, an applicant and an adviser.
Several people fill in the same response at once and see each other's answers, focus and cursors. On the client it is one plugin, CollaborationPlugin from survey-core/collaboration (v3.1.1+). It emits small messages, applies incoming ones, and draws the participant bar, focus rings and cursors itself. Your server is a relay: it keeps each room's definition and the latest value of every answer, sends both to a newcomer in one init message, stores and forwards each change, and passes presence through. It never parses the definition or the answers. Last write wins per question. Saving the finished response stays the I.1 endpoint.
This relay is app/Relay/FillRelay.php, run as its own process with php artisan relay:fill (port 8081).
Try this
The link carries your sandbox id: whoever opens it works in your copy of the demo data, in the same rooms.
Code that just ran
These regions are read from the files that served this page, the same lines the Server Integration page shows.
shared/client/fill-together.js · sjs:I.8.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:I.8.server
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);
}
}
Definition: shared/definitions/fill-together.json