Skip to content

Websockets

What are websockets

We use websockets to send small packages to users on the same pages. This way they get updates for the page they are using without having to reload the page.

How we use websockets

We use sockets to control the report locking - unlocking on the daily-presence page.
Our flow of what this connection needs is roughly like the following

flow
ReportOverview.vue -> locked.ts -> api/daily-registrations.php
-> Controllers -> ReportLock -> Events

This guide will help you find all the places the websockets might break and what happens there.

Debugging Websockets

Aside from reading out the requests, websockets appear in the inspect-tool at network/ws.
To locally test websockets you can log in to different accounts in different browsers, turn off caseload-mode, and see if reports update from one browser to the other without refreshing.

TIP

For websockets to work locally artisan reverb:start & artisan queue:listen need to be running. And you can run reverb:start --debug instead to read out socket events in your terminal.

Frontend - Laravel reverb

Websocket Construction

Our construction of the websocket happens in vue-services/websocket/index.ts.
At time of writing the setting there where the following.

ts
const options = {
    broadcaster: <const>'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    wssPort: import.meta.env.VITE_REVERB_PORT,
    cluster: import.meta.env.VITE_REVERB_APP_CLUSTER,
    forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
    encrypted: true,
    disableStats: true,
    enableTransports: ['ws', 'wss'],
    authEndpoint: '/api/broadcasting/auth',
};

export const websocket = new Echo({
    ...options,
    client: new Pusher(options.key, options),
});

It's important most of this happens through .env, as the settings your local installation wants differ from what Staging and Production need. Why this is get's explained more in the chapter Staging.

Socket Creation Listening

In the following code block is a rough example of the websocket-receiver side in the frontend.
The comments and imports are mostly here to clarify.

ts
/* ReportOverview.Vue */
import {websocket} from 'vue-services/websocket/index';
import {onReportLocked, onReportCreatedOrUpdated} from '../logic/scheduledRegistrations';

const getChannel = () => window.location.host.split('.')[0];
const channel = websocket.join(`reports.${getChannel()}`);

const setChannelListeners = () => {
    channel.listen('.report.locked', onReportLocked);
    channel.listen('.report.updated', onReportCreatedOrUpdated);
    channel.listen('.report.created', onReportCreatedOrUpdated);
};

onMounted(async () => {
    /* Raw Datafetching for the rest of the file */

    await checkForLockedRegistrations();
    setChannelListeners();
});
onUmmounted(() => websocket.leave(`reports.${getChannel()}`));

It's important to also unmount the websockets so that we don't listen to events on pages we're no longer visiting.

Sending Requests

We can send updates through the socket from normal crud events, or specifically called events. Through normal crud will be explained in Crud Controllers, here is the explanation for alterned events.

In the the daily-registration domain we call the locking of reports in the TableRow.vue file of both daily and ambulatory reports. Below are short excerpts:

ts
/* Excerpt from TableRow.vue */
const inEditing = ref(false);

const startEdit = (notPresentInFuture = false) => {
    lockRegistration(dailyRegistration);
    isEditing.value = true;
    if (notPresentInFuture) setSignedOffToTrue(editableRegistration.value);

    return editableRegistration.value;
};
/* This gets called emitted back up on cancel and submit */
const stopEditing = () => {
    isEditing.value = false;
    unlockRegistration(dailyRegistration);
};

Custom Events

We use custom events for the locking logic of Reports.
This makes it so that only one person can edit/create that specific report at a time.

ts
/* domains/registrations/logic/locked.ts */
import {postRequest} from 'vue-services/http';
import {websocket} from 'vue-services/websocket';

export const lockRegistration = report => {
    const {clientId, date} = report;
    postRequest(
        'reports/websocket/lock',
        {clientId, dateOfRegistration: date, type: reportToCareTypeEnumValue(report)},
        {headers: {'X-Socket-ID': websocket.socketId()}},
    );
};

Through this postRequest we only lock the specific report for 1 person, on 1 day, with 1 careType. This is also where we specify the api/route to take and can add aditional headers to the request.

Backend - Laravel Echo

We reach the Backend through either a custom event postRequest or through our normal crud, if we come here through the normal crud you won't need custom routes and can go straight to Controllers.

Custom Routes from Custom Events

php
/* routes/api/daily-registrations.php */
Route::group(['prefix' => 'websocket'], static function(): void {
    Route::post('/lock', [ReportBroadcastController::class, 'lockRegistration']);
});

By grouping it all with the websocket prefix it's groupable also for middleware.

Controllers / BroadcastControllers

General rule of thumb on which Controller type to use is whether your event is a broadcasted CRUD event or a custom event.

CRUD Controllers

For normal CRUD events we can insert the event near the end of the function called like this:

php
/* ReportController.pgp */
public function store(CreateThing $request): JsonResponse
{
    /* query validation and fixing the relations */

    /* The call to send report to other devices */
    broadcast(new ReportCreated($registration));

    /* The normal return */
    return new ReportPerDayResponse($registrationDate, 'Registratie is aangemaakt', $registration->id);
}

This will then send the same registration created through the broadcasting event to the other connected devices.
If you're making a lot of events you can consider making a subfolder for Events concerning this domain. (e.g. App\Events\Report\Created.php)

BroadcastControllers

For all intends and purposes they function like simple controllers. Here we validate the requests that don't go through the normal controller as they're websocket only like lock and getLocked.

php
/* ReportBroadcastController.php */
use App\ReportLock\DatabaseGetter;
use App\ReportLock\DatabaseReportLocker;
/**
 * Get all locked Reports, this fires after landing on the ReportOverview.vue page
 */
public function getLockedReports(): JsonResponse
{
    $registrations = $this->reportGetter->getAllReports();

    return new JsonResponse(['reports' => LockedReportsResource::collection($registrations)]);
}

/**
 * Validate locked reports and send them through to ReportLock
 */
public function lockRegistration(LockReportRequest $request): void
{
    $validated = $request->validated();

    $this->reportLocker->lock($validated['client_id'], $validated['date_of_report'], $validated['type']);
}

Both of these use an import from the ReportLock folder which is where we temporarily store the data on which reports are locked. If we don't do this refreshed pages or devices that navigate here after the event wouldn't get the updated lock list.

ReportLock

In the app\ReportLock\DatabaseGetter.php the database fetching gets done.
DataBaseReportLocker.php however contains the storage logic for the Database.
So if this doesnt work check this ReportLock folder to see where it goes wrong.

Events

After the DB storage has been done we get send through to the broadcast events. At time of writing we only have ReportLocker related events so they aren't subfoldered, this might change.

php
/* Excerpt from Events/ReportLocked.php */
public function __construct(
        private readonly int $userId,
        private readonly int $clientId,
        private readonly string $dateOfRegistration,
        private readonly string $type,
    ) {
        /* Gets rid of a lot of errors */
        $this->dontBroadcastToCurrentUser();
    }

    /**
     * The channel to broadcast on.
     */
    public function broadcastOn(): PresenceChannel
    {
        /* It's important to broadcast with the tenant name, otherwise data will get send across tenants */
        $channel = 'reports.' . tenant()->name;

        return new PresenceChannel($channel);
    }

    /**
     * The event to broadcast as.
     */
    public function broadcastAs(): string
    {
        return 'report.locked'; // 1.
    }

    /**
     * The data to broadcast.
     *
     * @return array<string, mixed>
     */
    public function broadcastWith(): array
    {
        return [
            'userId' => $this->userId,
            'clientId' => $this->clientId,
            'dateOfRegistration' => $this->dateOfRegistration,
            'type' => $this->type,
        ];
    }
  1. Remember the channels from Socket Creation Listening? This is where they return.

We do this with each broadcasted event in their own file.

Staging - Laravel Forge

The biggest problem moving up from development to staging is that you need secure connections in your headers (http to https).

Nginx Configuration

We've done this in staging by adding the following Nginx code to the staging.emmie.nl site in forge. The button to go there is under the Edit Files dropdown. (.env is also here.)

NGINX
# REVERB
    location /app {
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header SERVER_PORT $server_port;
        proxy_set_header REMOTE_ADDR $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_pass http://0.0.0.0:6001;
    }

    location /apps {
        proxy_pass http://0.0.0.0:6001/apps;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

Here 2 folders get a seperate proxy header set to them.

  • App: Where the broadcast events are fired from the backend (Laravel Echo)
  • Apps: Where the listening happens (Laravel Reverb)

Both have to be proxied here.

Daemon commands

Finally if you go back to the sites (overarching) part of Forge in the sidebar we have Daemons. We have two daemons active to run websockerts:

  1. php backend/artisan reverb:start
  2. php backend/artisan queue:listen

Both are needed for websockets to work.