<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://frantisekstanko.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://frantisekstanko.com/" rel="alternate" type="text/html" /><updated>2026-04-05T21:32:03+02:00</updated><id>https://frantisekstanko.com/feed.xml</id><title type="html">Frantisek Stanko</title><subtitle>Software engineer&apos;s musings, thoughts, and experiments.</subtitle><author><name>Frantisek Stanko</name></author><entry><title type="html">Building a progressive web application</title><link href="https://frantisekstanko.com/2025/06/15/building-a-progressive-web-application/" rel="alternate" type="text/html" title="Building a progressive web application" /><published>2025-06-15T00:00:00+02:00</published><updated>2025-06-15T00:00:00+02:00</updated><id>https://frantisekstanko.com/2025/06/15/building-a-progressive-web-application</id><content type="html" xml:base="https://frantisekstanko.com/2025/06/15/building-a-progressive-web-application/"><![CDATA[<p>I recently finished developing a progressive web application
for a local taxi company owned by a friend.</p>

<p>The story begins like this: one day, while my friend was driving me home,
he handed me his phone and asked me to enter several rows into an Excel spreadsheet.
The rows represented orders that he had received from customers over the phone.
After that, he had to call the drivers working for him to inform them about the new orders.
Furthermore, those drivers had to either memorize this information,
or jot it down in a notebook. This created a lot of cognitive load
and sometimes led to customers being forgotten.
I was struck by how inefficient this process was and began wondering whether I could
develop a tailored solution to streamline their operations.
After all, what better way to spend a weekend than by engaging with new technologies?</p>

<p>In this article, I will share how it turned out, what challenges I faced,
how I solved them, and what I learned along the way.</p>

<h2 id="overview">Overview</h2>

<h3 id="the-result">The result</h3>

<p>First, a few photos of the app being in use. Luckily my friend is also a photographer,
so he took some great photos for me during real-world use. The light and dark themes follow.</p>

<p><a href="/assets/images/1.jpg" target="_blank">
  <img src="/assets/images/1.jpg" />
</a>
<a href="/assets/images/2.jpg" target="_blank">
  <img src="/assets/images/2.jpg" />
</a></p>

<p>In summary, the app feels native on both iOS and Android, works fully offline
(for example, users can create new orders or edit existing ones while being offline),
synchronizes quickly with other instances when online, or right after coming online
(typically within 500 milliseconds), supports native push notifications using
web standards (the notifications work even on Apple Watches, so some drivers
sometimes do not have to use their phones at all), can be updated on client devices
easily within a few seconds with a relatively safe release process as far as I have experienced
and is blazing fast (as a long-time user of TUIs, I just <em>love</em> fast UIs).</p>

<h3 id="the-method">The method</h3>

<p>In line with the agile manifesto, I chose to build a proof of concept quickly
and, if it proved functional and usable, to continue improving it iteratively;
fixing issues we found and refactoring the code as needed.
To avoid falling into the abyss of unmaintainable code, I decided to follow
the recommendations of static analysis tools from the very first line of code,
adhere to all <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a> principles,
design the system in hexagonal architecture and by all means, avoid
over-engineering for future requirements.
I also made use of GitHub’s Copilot to go faster.</p>

<p>The app was deployed for production testing after 50 hours of work (roughly
five days). Then we had a month-long period of adding features, fixing bugs and
refactoring the code.</p>

<p>Over the past two months, no new features have been added and no bugs discovered.
The system now works maintenance-free and the taxi company can no longer imagine
reverting to their previous way of working.
They now make <strong>50% to 70% fewer phone calls</strong> to each other every month.
Drivers report being happier at work and finding their daily driving easier
and more enjoyable.</p>

<p><a href="/assets/images/3.jpg" target="_blank">
  <img src="/assets/images/3.jpg" />
</a></p>

<h3 id="the-tech-stack">The tech stack</h3>

<ul>
  <li>Frontend: <strong>TypeScript</strong></li>
  <li>Backend API: <strong>PHP</strong> (The sole reason I chose PHP was that I could build the
backend fastest in it, allowing us to begin production testing as soon as possible.
If I were to continue refactoring and improving the system, I would rework the
backend in <strong>Node.js</strong>; or in <strong>Rust</strong>, if I were aiming for maximum performance.)</li>
  <li>Websocket server: <strong>TypeScript</strong> running in <strong>Node.js</strong></li>
  <li>Push server: <strong>TypeScript</strong> running in <strong>Node.js</strong></li>
  <li>Production: DigitalOcean</li>
</ul>

<p>We could consider the <strong>service worker</strong> as a separate piece of the stack,
as it has its own build process, however, I will consider it a part of the
frontend itself, although it will receive its own, separate section.</p>

<h3 id="code-quality">Code quality</h3>

<ul>
  <li><strong>Jest</strong> for unit testing TypeScript</li>
  <li><strong>Puppeteer</strong> for e2e flow testing</li>
  <li><strong>PHPUnit</strong> for unit testing PHP</li>
  <li><strong>Eslint 9</strong> for static analysis of TypeScript code</li>
  <li><strong>PHPStan 2</strong> for static analysis of PHP code</li>
</ul>

<p>Let us now go through all the pieces of the puzzle one by one and see how
it all works together.</p>

<p><a href="/assets/images/4.jpg" target="_blank">
  <img src="/assets/images/4.jpg" />
</a></p>

<h2 id="the-frontend">The frontend</h2>

<p>The frontend was built offline-first, meaning that when the app is first
loaded in the browser, its service worker caches all assets for later offline
use. Also, an “install this app” option is presented on mobile devices and
on desktop browsers, which when used, installs the app to the home screen
and prepares it for offline use.</p>

<p>Subsequently, the app can be opened and used fully offline without an internet
connection. The user can use the app in its entirety, i.e.
can create new drivers, can create new orders, assign orders to drivers, etc.</p>

<p>If the user has a valid authentication token and is online, their data is instantly
being synced to the backend and pushed to other devices authenticated to the same
company, but this is not a strict requirement.</p>

<h3 id="the-frontend-code">The frontend code</h3>

<p>To illustrate how this works, let’s look at some code. The frontend
code of the app is located in <code class="language-plaintext highlighter-rouge">src/</code>. The following is <code class="language-plaintext highlighter-rouge">src/index.html</code>,
the entry point to the app:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!doctype html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"manifest"</span> <span class="na">href=</span><span class="s">"manifest.json"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;title&gt;</span>Taxi<span class="nt">&lt;/title&gt;</span>
    <span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"module"</span> <span class="na">src=</span><span class="s">"main.ts"</span><span class="nt">&gt;&lt;/script&gt;</span>
    <span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">href=</span><span class="s">"../sass/main.scss"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;/head&gt;</span>
  <span class="nt">&lt;body</span> <span class="na">style=</span><span class="s">"opacity: 0"</span><span class="nt">&gt;&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>The build process is started with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>parcel build src/index.html <span class="se">\</span>
    <span class="nt">--dist-dir</span> dist <span class="se">\</span>
    <span class="nt">--public-url</span> /
</code></pre></div></div>

<p><a href="https://parceljs.org/">Parcel</a> then traverses all the files included in <code class="language-plaintext highlighter-rouge">index.html</code>
and bundles them into the <code class="language-plaintext highlighter-rouge">dist/</code> directory. One of those files, as you can see,
is <code class="language-plaintext highlighter-rouge">src/main.ts</code>, which is the TypeScript entry point to the app and this is
how it looks in its entirety:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span>
    <span class="nx">ServiceContainer</span>
<span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@Taxi/Shared/Infrastructure/ServiceContainer</span><span class="dl">'</span>

<span class="kd">const</span> <span class="nx">serviceContainer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ServiceContainer</span><span class="p">()</span>

<span class="nx">serviceContainer</span><span class="p">.</span><span class="nf">getRouter</span><span class="p">().</span><span class="nf">handle</span><span class="p">(</span>
    <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span>
<span class="p">)</span>

<span class="nx">serviceContainer</span><span class="p">.</span><span class="nf">getApp</span><span class="p">().</span><span class="nf">fadeIn</span><span class="p">()</span>

<span class="nx">serviceContainer</span>
  <span class="p">.</span><span class="nf">getSyncService</span><span class="p">()</span>
  <span class="p">.</span><span class="nf">start</span><span class="p">()</span>
  <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="nx">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="nx">error</span><span class="p">)</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">SyncService failed to start</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">})</span>

<span class="nx">serviceContainer</span>
  <span class="p">.</span><span class="nf">getServiceWorkerFactory</span><span class="p">()</span>
  <span class="p">.</span><span class="nf">registerServiceWorker</span><span class="p">()</span>
  <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="nx">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="nx">error</span><span class="p">)</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Error registering service worker</span><span class="dl">'</span><span class="p">)</span>
  <span class="p">})</span>
</code></pre></div></div>

<p>The routes in the router look like this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/orders/history</span><span class="dl">'</span><span class="p">,</span> <span class="nx">ordersHistoryController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/active-orders</span><span class="dl">'</span><span class="p">,</span> <span class="nx">activeOrdersController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/orders/new</span><span class="dl">'</span><span class="p">,</span> <span class="nx">newOrderController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/order/{1}</span><span class="dl">'</span><span class="p">,</span> <span class="nx">orderDetailController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/order/{1}/edit</span><span class="dl">'</span><span class="p">,</span> <span class="nx">editOrderController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/drivers</span><span class="dl">'</span><span class="p">,</span> <span class="nx">driversController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/driver/{1}</span><span class="dl">'</span><span class="p">,</span> <span class="nx">driverDetailController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/drivers/new</span><span class="dl">'</span><span class="p">,</span> <span class="nx">newDriverController</span><span class="p">)</span>
<span class="nx">router</span><span class="p">.</span><span class="nf">addRoute</span><span class="p">(</span><span class="dl">'</span><span class="s1">/settings</span><span class="dl">'</span><span class="p">,</span> <span class="nx">settingsController</span><span class="p">)</span>
</code></pre></div></div>

<p>Controllers implement the <em>ControllerInterface</em>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kr">interface</span> <span class="nx">ControllerInterface</span> <span class="p">{</span>
  <span class="nf">handle</span><span class="p">():</span> <span class="k">void</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When a <code class="language-plaintext highlighter-rouge">handle()</code> of a controller is called, it builds the entire HTML that
needs to be presented on the screen and inserts it into the DOM.</p>

<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">IndexedDB</a>
is used to store all local data for offline use. This is done through a <em>DatabaseInterface</em>
and relevant services.</p>

<p>(One interesting thing that caught me off guard is that IndexedDB does not index
<code class="language-plaintext highlighter-rouge">null</code> values and thus does not let you query for values that are <code class="language-plaintext highlighter-rouge">null</code>.)</p>

<p>Let us look at an example: an order being marked as finished.</p>

<p><code class="language-plaintext highlighter-rouge">OrderDetailController</code> renders a button which can be clicked:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">finishOrderButton</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span>
  <span class="dl">'</span><span class="s1">click</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">orderService</span><span class="p">.</span><span class="nf">finishOrder</span><span class="p">(</span><span class="nx">orderId</span><span class="p">)</span>
  <span class="p">},</span>
<span class="p">)</span>
</code></pre></div></div>

<p>In our <code class="language-plaintext highlighter-rouge">OrderService</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nc">OrderService</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">(</span>
    <span class="k">private</span> <span class="nx">orderRepository</span><span class="p">:</span> <span class="nx">OrderRepositoryInterface</span><span class="p">,</span>
  <span class="p">)</span> <span class="p">{}</span>

  <span class="k">public</span> <span class="k">async</span> <span class="nf">finishOrder</span><span class="p">(</span><span class="nx">orderId</span><span class="p">:</span> <span class="nx">OrderId</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">order</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">orderRepository</span><span class="p">.</span><span class="nf">getById</span><span class="p">(</span>
      <span class="nx">orderId</span>
    <span class="p">)</span>

    <span class="nx">order</span><span class="p">.</span><span class="nf">finish</span><span class="p">()</span>

    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">orderRepository</span><span class="p">.</span><span class="nf">persist</span><span class="p">(</span><span class="nx">order</span><span class="p">)</span>

    <span class="nx">order</span><span class="p">.</span><span class="nf">releaseEvents</span><span class="p">().</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">eventBus</span><span class="p">.</span><span class="nf">dispatchEvent</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
    <span class="p">})</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is the <code class="language-plaintext highlighter-rouge">finish()</code> part of the <code class="language-plaintext highlighter-rouge">Order</code> entity:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="nf">finish</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nf">assertNotCancelled</span><span class="p">()</span>
  <span class="k">this</span><span class="p">.</span><span class="nf">assertNotFinished</span><span class="p">()</span>

  <span class="k">this</span><span class="p">.</span><span class="nx">finished</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">lastUpdate</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">()</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">requiresSync</span> <span class="o">=</span> <span class="kc">true</span>

  <span class="k">this</span><span class="p">.</span><span class="nf">recordEvent</span><span class="p">(</span>
    <span class="k">new</span> <span class="nc">OrderWasEdited</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">orderId</span><span class="p">)</span>
  <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And at last, <code class="language-plaintext highlighter-rouge">persist()</code> is called on the <code class="language-plaintext highlighter-rouge">OrderRepositoryInterface</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">Order</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@Taxi/Orders/Domain/Order</span><span class="dl">'</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">OrderId</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@Taxi/Orders/Domain/OrderId</span><span class="dl">'</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">OrderRepositoryInterface</span> <span class="p">{</span>
  <span class="nf">getById</span><span class="p">(</span><span class="nx">orderId</span><span class="p">:</span> <span class="nx">OrderId</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">Order</span><span class="o">&gt;</span>
  <span class="nf">persist</span><span class="p">(</span><span class="nx">order</span><span class="p">:</span> <span class="nx">Order</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The implementation of <code class="language-plaintext highlighter-rouge">persist()</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nc">OrderRepository</span> <span class="k">implements</span> <span class="nx">OrderRepositoryInterface</span>
<span class="p">{</span>
  <span class="k">private</span> <span class="k">readonly</span> <span class="nx">orders</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">orders</span><span class="dl">'</span>

  <span class="nf">constructor</span><span class="p">(</span>
    <span class="k">private</span> <span class="nx">database</span><span class="p">:</span> <span class="nx">DatabaseInterface</span><span class="p">,</span>
  <span class="p">)</span> <span class="p">{}</span>

  <span class="nf">persist</span><span class="p">(</span><span class="nx">order</span><span class="p">:</span> <span class="nx">Order</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">database</span><span class="p">.</span><span class="nf">save</span><span class="p">(</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">orders</span><span class="p">,</span> <span class="nx">order</span><span class="p">.</span><span class="nf">toDatabase</span><span class="p">()</span>
    <span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SyncService</code> listens to the <code class="language-plaintext highlighter-rouge">OrderWasEdited</code> event and when caught,
it triggers a sync:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">;[</span>
  <span class="nx">NewOrderWasCreated</span><span class="p">,</span>
  <span class="nx">NewDriverWasCreated</span><span class="p">,</span>
  <span class="nx">OrderWasCancelled</span><span class="p">,</span>
  <span class="nx">OrderWasEdited</span><span class="p">,</span>
  <span class="nx">OrderWasAccepted</span><span class="p">,</span>
  <span class="nx">DriverWasActivated</span><span class="p">,</span>
  <span class="nx">DriverWasDeactivated</span><span class="p">,</span>
  <span class="nx">DriverWasDeleted</span><span class="p">,</span>
<span class="p">].</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">eventClass</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">eventBus</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="nx">eventClass</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">void</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushData</span><span class="p">()</span>
  <span class="p">})</span>
<span class="p">})</span>
</code></pre></div></div>

<p>This could be refactored so this list does not grow indefinitely, however,
I was hesitant to extend these events from a common base class, as that
can easily break SOLID. An interface would be a clean solution, however,
in TypeScript there are no interfaces at runtime.</p>

<p>To avoid pushing and pulling data at the same time, <code class="language-plaintext highlighter-rouge">SyncService</code> leverages
a <strong>promise queue</strong>, such as this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nc">SyncService</span> <span class="p">{</span>
  <span class="k">private</span> <span class="nx">syncQueue</span><span class="p">:</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="o">=</span> <span class="nb">Promise</span><span class="p">.</span><span class="nf">resolve</span><span class="p">()</span>

  <span class="k">private</span> <span class="k">async</span> <span class="nf">pushData</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">syncQueue</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">syncQueue</span>
      <span class="p">.</span><span class="nf">then</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">executePush</span><span class="p">())</span>
      <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="na">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
          <span class="k">void</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushData</span><span class="p">()</span>
        <span class="p">},</span> <span class="k">this</span><span class="p">.</span><span class="nx">retryDelay</span><span class="p">)</span>
      <span class="p">})</span>

    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">syncQueue</span>
  <span class="p">}</span>

  <span class="k">private</span> <span class="k">async</span> <span class="nf">pullData</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">syncQueue</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">syncQueue</span>
      <span class="p">.</span><span class="nf">then</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nf">executePull</span><span class="p">())</span>
      <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="na">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
          <span class="k">void</span> <span class="k">this</span><span class="p">.</span><span class="nf">pullData</span><span class="p">()</span>
        <span class="p">},</span> <span class="k">this</span><span class="p">.</span><span class="nx">retryDelay</span><span class="p">)</span>
      <span class="p">})</span>

    <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">syncQueue</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This assures that if whatever event triggers a push or a pull, it will
actually trigger only after the whole queue has finished (or immediately,
if it’s empty). Thus, a pull and a push operation cannot run at the same time in parallel.</p>

<p>The actual push, when it gets on the queue, looks like this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">async</span> <span class="nf">executePush</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">orders</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">database</span><span class="p">.</span><span class="nf">getAll</span><span class="p">(</span>
    <span class="dl">'</span><span class="s1">orders</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">indexName</span><span class="p">:</span> <span class="dl">'</span><span class="s1">requiresSync</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">value</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
    <span class="p">}</span>
  <span class="p">})</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">orders</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushOrders</span><span class="p">(</span><span class="nx">orders</span><span class="p">)</span>
  <span class="p">}</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">loggedInUser</span><span class="p">.</span><span class="nf">isDriver</span><span class="p">())</span> <span class="p">{</span>
    <span class="k">return</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">drivers</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">database</span><span class="p">.</span><span class="nf">getAll</span><span class="p">(</span>
    <span class="dl">'</span><span class="s1">drivers</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">indexName</span><span class="p">:</span> <span class="dl">'</span><span class="s1">requiresSync</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">value</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
    <span class="p">}</span>
  <span class="p">})</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">drivers</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushDrivers</span><span class="p">(</span><span class="nx">drivers</span><span class="p">)</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Since we’re dealing with a chained promise queue, <code class="language-plaintext highlighter-rouge">pushOrders</code> can be implemented
in any way, as long as it throws an exception when a problem occurs and it will be
automatically retried later.</p>

<p>When an order is successfully pushed to the server, the <code class="language-plaintext highlighter-rouge">lastUpdate</code> time
is set to the current time of the server <strong>and</strong> the order is sent back
to the client in the response of the push request. The client replaces
their local order with this data.</p>

<p>Additionally, when a successful pull is made by a client, the current
time of the server is included in the response and saved on the client
side as the time of the last successful pull. This timestamp is included in every
pull request. This means that no change is ever missed.</p>

<h3 id="two-sources-of-truth-for-time">Two sources of truth for time</h3>

<p>Initially, I made it <strong>a rule</strong> that if someone pushed data with a <code class="language-plaintext highlighter-rouge">lastUpdate</code> timestamp
older than what the server already held, the push would be rejected. I assumed this
would guard against those rare edge cases in which an offline device modifies an entry
that has, in the meantime, been altered by another online device.</p>

<p>It didn’t take long before the warning log began growing faster than I had anticipated –
those cases turned out to be more common than expected.</p>

<p>After adding debugging information to the logs and investigating further,
I discovered the source of the problem: the clocks on client devices,
which could be off by tens of seconds. What was happening was this:
a client would push an <code class="language-plaintext highlighter-rouge">Order</code> to the backend, which would update its timestamp
to the backend’s current time and return the modified data to the client.
While the client did replace its local copy with the server’s version,
it was still possible for the client to make further edits and push the order again.
However, because the client’s clock lagged more than 10 seconds behind the server’s,
the backend considered its own version “newer” and rejected the update.</p>

<p>So I changed <strong>the rule</strong>: reject older data from a client only if the current
data was pushed by a different client. Otherwise, allow the same client
to submit new values with any timestamp. After this change, the problem was resolved,
and the warning log ceased growing.</p>

<h2 id="websocket">WebSocket</h2>

<h3 id="pushing-the-data-to-other-clients">Pushing the data to other clients</h3>

<p>Next, we need to tell other client devices to pull the new changes.</p>

<p>For that we have a WebSocket server and leverage Redis’ Pub/Sub to communicate
with the backend API. When a new order is pushed to the backend,
a message is published to Redis, which when received by the WebSocket server,
is forwarded to all clients connected to it, telling them that they should perform a pull.
A good thing about this approach is that it spawns another thread and is non-blocking
to the original process, meaning the push request can send a <code class="language-plaintext highlighter-rouge">HTTP 200</code> response
immediately and no one has to wait for anything. Also, no one ever has to poll.
All thanks to WebSocket.</p>

<p>All the backend has to do is this:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>

<span class="k">final</span> <span class="kd">class</span> <span class="nc">PushDataController</span> <span class="kd">implements</span> <span class="nc">RequestHandlerInterface</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">private</span> <span class="kt">RedisClientInterface</span> <span class="nv">$redisClient</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span>
        <span class="kt">ServerRequestInterface</span> <span class="nv">$request</span><span class="p">,</span>
    <span class="p">):</span> <span class="kt">ResponseInterface</span>
    <span class="p">{</span>
        <span class="cd">/**
        * Updates are performed;
        * not visible in this snippet.
        */</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$updatesPerformed</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">redisClient</span><span class="o">-&gt;</span><span class="nf">publish</span><span class="p">(</span>
                <span class="s1">'websocket'</span><span class="p">,</span>
                <span class="s1">'remote-data-changed'</span><span class="p">,</span>
            <span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The WebSocket server listens to redis messages and forwards them to clients:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">redis</span><span class="p">.</span><span class="nf">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">message</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">channel</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">channel</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">websocket</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">authenticatedClients</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">client</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">client</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span>
    <span class="p">})</span>
  <span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<p>The WebSocket client listens to these messages:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nc">WebSocketClient</span> <span class="p">{</span>
  <span class="k">private</span> <span class="nx">socket</span><span class="p">:</span> <span class="nx">WebSocket</span>

  <span class="nf">constructor</span><span class="p">(</span>
    <span class="k">private</span> <span class="nx">eventBus</span><span class="p">:</span> <span class="nx">EventBusInterface</span><span class="p">,</span>
  <span class="p">)</span> <span class="p">{</span>
    <span class="k">void</span> <span class="k">this</span><span class="p">.</span><span class="nf">connect</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="k">private</span> <span class="k">async</span> <span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">socket</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">WebSocket</span><span class="p">(</span>
      <span class="k">this</span><span class="p">.</span><span class="nf">getWebsocketUrl</span><span class="p">()</span>
    <span class="p">)</span>

    <span class="k">this</span><span class="p">.</span><span class="nx">socket</span><span class="p">.</span><span class="nx">onmessage</span> <span class="o">=</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">data</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">remote-data-changed</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">this</span><span class="p">.</span><span class="nx">eventBus</span><span class="p">.</span><span class="nf">dispatchEvent</span><span class="p">(</span>
          <span class="k">new</span> <span class="nc">RemoteDataWasChanged</span><span class="p">()</span>
        <span class="p">)</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And finally, in the <code class="language-plaintext highlighter-rouge">SyncService</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">this</span><span class="p">.</span><span class="nx">eventBus</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span>
  <span class="nx">RemoteDataWasChanged</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">void</span> <span class="k">this</span><span class="p">.</span><span class="nf">pullData</span><span class="p">()</span>
  <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<p>This means that now we have a two-way backend-to-client communication that
is instant and requires no polling. WebSockets are nice.</p>

<p>I was amazed by how fast this was from the start. It typically takes <strong>less than 500 milliseconds</strong>
for new orders to appear on other client devices after being created on one device.</p>

<p>The websocket server execution is managed by systemd:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Unit]
Description=WebSocket for Taxi
After=network.target mariadb.service redis-server.service
Requires=mariadb.service redis-server.service
BindsTo=mariadb.service redis-server.service

[Service]
Type=simple
User=www-data
Environment=NODE_ENV=production
WorkingDirectory=/var/www/websocket/
ExecStart=node /var/www/websocket/dist/server.js
RestartSec=3
Restart=always

[Install]
WantedBy=multi-user.target
</code></pre></div></div>

<p>To improve things further, the sync is performed whenever the WebSocket
is (re)connected, as pull requests could have been missed if the websocket
was disconnected for whatever reason:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">this</span><span class="p">.</span><span class="nx">eventBus</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span>
  <span class="nx">WebSocketWasConnected</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">void </span><span class="p">(</span><span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushData</span><span class="p">()</span>
      <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">pullData</span><span class="p">()</span>
    <span class="p">})()</span>
  <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<p>If the WebSocket server is unavailable, the app falls back to slow polling
and performs a pull every 60 seconds.</p>

<h3 id="one-more-websocket-lesson">One more WebSocket lesson</h3>

<p>WebSocket server implementations do not usually implement HTTPS handling themselves.
This is normally handled by the webserver. So I leveraged apache2 for that.</p>

<p>The websocket server itself runs on a local port 50000 and is then
proxied by the webserver with a https certificate to the open web:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;VirtualHost *:443&gt;
   ProxyRequests Off
   ProxyPass "/websocket" "ws://localhost:50000/"
   ProxyPassReverse "/websocket" "ws://localhost:50000/"
&lt;/VirtualHost&gt;
</code></pre></div></div>

<h2 id="service-worker-the-final-piece-of-the-offline-puzzle">Service Worker: The final piece of the offline puzzle</h2>

<p>Understanding how <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">service workers</a>
work was the hardest part of the whole project.</p>

<p>Service workers handle proxying fetch requests when
the network is offline and also make push notifications possible,
as they are constantly active, even when the app is not on the screen
(or even when the screen is off and locked).</p>

<p>First, it took me some time to figure out that
they must have their own <code class="language-plaintext highlighter-rouge">tsconfig.json</code>, as the context they run
in is different from the DOM context:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  "files": ["./service-worker.ts"],
  "compilerOptions": {
    "lib": ["ESNext", "WebWorker"],
  }
}
</code></pre></div></div>

<p>Then I had to learn how to cache assets inside the service worker.
Luckily, Parcel made it a bit easier,
as it generates a manifest file with all used assets, which can then be provided
to the service worker when building the project, such as this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">declare</span> <span class="kd">const</span> <span class="nb">self</span><span class="p">:</span> <span class="nx">ServiceWorkerGlobalScope</span>

<span class="k">import</span> <span class="p">{</span> <span class="nx">manifest</span><span class="p">,</span> <span class="nx">version</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@parcel/service-worker</span><span class="dl">'</span>

<span class="nb">self</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">install</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">skipWaiting</span><span class="p">()</span>

  <span class="nx">event</span><span class="p">.</span><span class="nf">waitUntil</span><span class="p">(</span>
    <span class="p">(</span><span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">cache</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="nx">version</span><span class="p">)</span>
      <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">url</span> <span class="k">of</span> <span class="nx">manifest</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">await</span> <span class="nx">cache</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="nx">url</span><span class="p">)</span>
      <span class="p">}</span>
    <span class="p">})(),</span>
  <span class="p">)</span>
<span class="p">})</span>
</code></pre></div></div>

<p>This is what creates the offline experience. When the connection is down,
the service worker returns the cached assets, without the client being aware
of the downed connection at all.</p>

<p>This creates a whole new set of problems, however: pushing new updates
to the clients. When an application is supposed to be fully offline,
how do we tell the clients that <em>now they have to actually fetch new
assets</em>?</p>

<h3 id="updating-the-app-on-the-client-side">Updating the app on the client side</h3>

<p>After a lot of struggle, I ended up with the following setup:</p>

<p>I can release updates in 4 “levels”:</p>

<ul>
  <li>
    <p>L1: an update which requires no reload on the client side
(this includes non-breaking updates to the backend, for example)</p>
  </li>
  <li>
    <p>L2: an update which requires a reload on the client side
<strong>during the next navigation attempt</strong>
(this includes updates that do not change the local database schema and perform
small UI fixes/improvements which are not needed to be shown “right away”)</p>
  </li>
  <li>
    <p>L3: an update which requires a reload on the client side
<strong>as soon as possible</strong>
(this includes updates that do not change the local database schema and perform
changes on the client which I want to be loaded as soon as possible)</p>
  </li>
  <li>
    <p>L4: an update which requires a “reboot” on the client side,
which also performs a full local database rebuild (these are now rare)</p>
  </li>
</ul>

<h3 id="release-script">Release script</h3>

<p>Releases are performed using a <code class="language-plaintext highlighter-rouge">bash</code> script which builds the assets
and then runs the tests. If everything is fine, it creates a release file.
During the build process, it asks interactively whether the client version
should be incremented; this means that a reload is required
on the client side as soon as possible; otherwise no reload shall be performed.</p>

<p>If the database version is incremented, a reboot is performed on the client side,
recreating the whole local database.</p>

<p>After a release is made, it is pushed to production.</p>

<p>The push to production looks like this (all automated by the script):</p>

<ol>
  <li>The release is <code class="language-plaintext highlighter-rouge">rsync</code>ed to a new, temporary location on the remote server</li>
  <li><code class="language-plaintext highlighter-rouge">MAINTENANCE=true</code> is set in the <code class="language-plaintext highlighter-rouge">.env</code> file. When this is set to true, the backend
will respond with <code class="language-plaintext highlighter-rouge">HTTP 503</code> to every request. This means
that if any clients are trying to push or pull data during the update,
their requests will fail and their apps will keep retrying (including a visual
indicator that a sync is pending). Keep in mind that the app is still fully usable
at this point, the only disabled feature is the sync.</li>
  <li>The release is moved on the remote from the temporary location to the production path</li>
  <li><code class="language-plaintext highlighter-rouge">MAINTENANCE</code> is removed from the <code class="language-plaintext highlighter-rouge">.env</code> file and the backend starts serving
requests again.</li>
  <li>The websocket server is optionally restarted, depending on what update
level I am aiming for.</li>
</ol>

<p>The 5-step push described above occurs in less than 5 seconds, thanks to the power
of <code class="language-plaintext highlighter-rouge">rsync</code>; the backend downtime is roughly half that time.</p>

<p>Client apps also have an online indicator. The green bottom bar that you see
on the photos turns red when the network is offline (websocket disconnected).</p>

<p>Orders that are unpushed and require sync are indicated in the table
with a specific, animated, “loading” background. Users usually do not see this,
as sync is most of the time instant.</p>

<p>If the WebSocket server is restarted, then all clients’ websocket connections
are reconnected. This triggers the following flow on the client side:</p>

<p>A pull is started:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">this</span><span class="p">.</span><span class="nx">eventBus</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span>
  <span class="nx">WebSocketWasConnected</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">void </span><span class="p">(</span><span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushData</span><span class="p">()</span>
      <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">pullData</span><span class="p">()</span>
    <span class="p">})()</span>
  <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<p>In the pull response, the current client version is included. If the version
running on the client device is older, the service worker is asked to update:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="nx">json</span><span class="p">.</span><span class="nx">serverVersion</span> <span class="o">!==</span> <span class="nx">clientVersion</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nf">updateServiceWorker</span><span class="p">()</span>
<span class="p">}</span>

<span class="k">private</span> <span class="nf">updateServiceWorker</span><span class="p">():</span> <span class="k">void</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">serviceWorkerFactory</span>
    <span class="p">.</span><span class="nf">updateServiceWorker</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">then</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Requested service worker to update</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Service worker update failed.</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One of the advantages of updating a service worker is that the new worker is
spawned in the background, while the old one continues to serve the app.
This allows the user to keep using the application uninterrupted.
Furthermore, if any of the <code class="language-plaintext highlighter-rouge">fetch()</code> requests during the install phase of the
new service worker fail (unless exceptions are explicitly caught),
the installation itself fails. In such cases, the update is retried later.
Crucially, the app does not enter a broken state, since the old service worker
remains active and continues to function normally.</p>

<p>If the service worker update succeeds, the new service worker is
ready to be started. For that to happen, however, the current
app window must be reloaded, as the old service worker must first be stopped.</p>

<p>We do that by sending a message from the service worker to all open windows:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">self</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">activate</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">event</span><span class="p">.</span><span class="nf">waitUntil</span><span class="p">(</span>
    <span class="p">(</span><span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">clients</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">self</span><span class="p">.</span><span class="nx">clients</span><span class="p">.</span><span class="nf">matchAll</span><span class="p">({</span>
        <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">window</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">includeUncontrolled</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
      <span class="p">})</span>

      <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">client</span> <span class="k">of</span> <span class="nx">clients</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">client</span><span class="p">.</span><span class="nf">postMessage</span><span class="p">({</span>
          <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">service_worker_updated</span><span class="dl">'</span>
        <span class="p">})</span>
      <span class="p">}</span>
    <span class="p">})(),</span>
  <span class="p">)</span>
<span class="p">})</span>
</code></pre></div></div>

<p>When the main app window registers this event, it reloads the page:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">navigator</span><span class="p">.</span><span class="nx">serviceWorker</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span>
  <span class="dl">'</span><span class="s1">message</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="kd">type</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">service_worker_updated</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
      <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nf">reload</span><span class="p">()</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<h3 id="rebuilding-the-local-database">Rebuilding the local database</h3>

<p>There is a way to execute local database migrations, as the IndexedDB
fires an <code class="language-plaintext highlighter-rouge">upgradeneeded</code> event and this event holds the old and the new version
number. However, making these migrations stable (and not time-consuming to develop)
turned out to be difficult, so I chose a different approach to handle this case.</p>

<p>Whenever I need to execute a full recreate on the client side, I increase the
local database version:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">export class LocalDatabase implements DatabaseInterface {
</span><span class="gd">-- private databaseVersion = 28
</span><span class="gi">++ private databaseVersion = 29
</span></code></pre></div></div>

<p>This triggers the following code:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">request</span><span class="p">.</span><span class="nx">onupgradeneeded</span> <span class="o">=</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">oldVersion</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/init.html</span><span class="dl">'</span>
    <span class="k">return</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In our <code class="language-plaintext highlighter-rouge">init.html</code>, we have a separate <code class="language-plaintext highlighter-rouge">init.ts</code> file, which contains bare minimum
code to destroy the local database, recreate it and pull all data anew.</p>

<p>One interesting problem I had to solve is that sometimes this didn’t work.
It is possible for an app window to be blocking the database, keeping it open. For those
cases, I wrote a nifty little hack.</p>

<p>A special “unload” message is dispatched before deleting the local database:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nf">deleteDatabase</span><span class="p">(</span>
  <span class="nx">name</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">channel</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">BroadcastChannel</span><span class="p">(</span>
    <span class="dl">'</span><span class="s1">unload-coordinator</span><span class="dl">'</span>
  <span class="p">)</span>
  <span class="nx">channel</span><span class="p">.</span><span class="nf">postMessage</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">unload</span><span class="dl">'</span> <span class="p">})</span>

  <span class="k">await</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">(</span>
    <span class="p">(</span><span class="nx">resolve</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">setTimeout</span><span class="p">(</span><span class="nx">resolve</span><span class="p">,</span> <span class="mi">1000</span><span class="p">)</span>
  <span class="p">)</span>

  <span class="k">return</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">((</span><span class="nx">resolve</span><span class="p">,</span> <span class="nx">reject</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">request</span> <span class="o">=</span> <span class="nx">indexedDB</span><span class="p">.</span><span class="nf">deleteDatabase</span><span class="p">(</span><span class="nx">name</span><span class="p">)</span>

    <span class="nx">request</span><span class="p">.</span><span class="nx">onsuccess</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nf">resolve</span><span class="p">()</span>
    <span class="p">}</span>
    <span class="nx">request</span><span class="p">.</span><span class="nx">onerror</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nf">reject</span><span class="p">(</span><span class="k">new</span> <span class="nc">Error</span><span class="p">(</span>
        <span class="nx">request</span><span class="p">.</span><span class="nx">error</span><span class="p">?.</span><span class="nx">message</span> <span class="o">??</span> <span class="dl">'</span><span class="s1">Unknown error</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">)</span>
    <span class="p">}</span>
    <span class="nx">request</span><span class="p">.</span><span class="nx">onblocked</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nf">reject</span><span class="p">(</span><span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Database deletion blocked</span><span class="dl">'</span><span class="p">))</span>
    <span class="p">}</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>An <code class="language-plaintext highlighter-rouge">UnloadCoordinator</code> listens to this message and if received, redirects the user
to <code class="language-plaintext highlighter-rouge">/unload.html</code>, which is a simple, empty page with a button redirecting back
to the root:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nc">UnloadCoordinator</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">channel</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">BroadcastChannel</span><span class="p">(</span>
      <span class="dl">'</span><span class="s1">unload-coordinator</span><span class="dl">'</span>
    <span class="p">)</span>

    <span class="nx">channel</span><span class="p">.</span><span class="nx">onmessage</span> <span class="o">=</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="p">{</span> <span class="kd">type</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">data</span> <span class="kd">as </span><span class="nx">unknown</span> <span class="kd">as </span><span class="p">{</span>
        <span class="na">type</span><span class="p">:</span> <span class="kr">string</span>
      <span class="p">}</span>

      <span class="k">if </span><span class="p">(</span><span class="kd">type</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">unload</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
        <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/unload.html</span><span class="dl">'</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Interestingly, neither I nor the customer have ever seen this screen on production.
However, it did solve the database reinitialization problem completely.</p>

<h2 id="push-notifications">Push notifications</h2>

<p>In the beginning, I was terrified of push notifications. Surprisingly,
they turned out to be the most stable and reliable feature of the app.</p>

<p>Since a few years ago, push notifications can finally be implemented in
progressive web apps natively without having to resort to Firebase.</p>

<p>Push notifications are handled by the service worker. When a push notification
permission is granted by the user, we tell the push manager to register
a push subscription. The app requests the public VAPID key of our push server
and includes it with the push registration. The registration itself is done by
the web browser (same for iOS and Android). It usually calls either
<code class="language-plaintext highlighter-rouge">https://web.push.apple.com/</code> or <code class="language-plaintext highlighter-rouge">https://fcm.googleapis.com/</code>.</p>

<p>If a new subscription is successfully created, it is sent to our backend
and stored in persistence. When we need to send a push notification to this
user, this subscription is used.</p>

<p>The whole flow looks like this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">permission</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Notification</span><span class="p">.</span><span class="nf">requestPermission</span><span class="p">()</span>

<span class="k">if </span><span class="p">(</span><span class="nx">permission</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">granted</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">registerSubscription</span><span class="p">()</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nb">window</span><span class="p">.</span><span class="nf">alert</span><span class="p">(</span><span class="nx">error</span><span class="p">)</span>
    <span class="nx">notificationsToggle</span><span class="p">.</span><span class="nf">disable</span><span class="p">()</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="k">private</span> <span class="k">async</span> <span class="nf">registerSubscription</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">registration</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">navigator</span><span class="p">.</span><span class="nx">serviceWorker</span><span class="p">.</span><span class="nx">ready</span>
  <span class="kd">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">registration</span><span class="p">.</span><span class="nx">pushManager</span><span class="p">.</span><span class="nf">getSubscription</span><span class="p">()</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">subscription</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">api</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/push/vapid-key</span><span class="dl">'</span><span class="p">)</span>
  <span class="kd">const</span> <span class="nx">vapidPublicKey</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>
  <span class="kd">const</span> <span class="nx">convertedVapidKey</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nf">urlBase64ToUint8Array</span><span class="p">(</span><span class="nx">vapidPublicKey</span><span class="p">)</span>
  <span class="kd">const</span> <span class="nx">newSubscription</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">registration</span><span class="p">.</span><span class="nx">pushManager</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">({</span>
    <span class="na">userVisibleOnly</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="na">applicationServerKey</span><span class="p">:</span> <span class="nx">convertedVapidKey</span><span class="p">,</span>
  <span class="p">})</span>

  <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">api</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/push/register</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">newSubscription</span><span class="p">.</span><span class="nf">toJSON</span><span class="p">()),</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>

<span class="k">final</span> <span class="kd">class</span> <span class="nc">RegisterPushController</span> <span class="kd">implements</span> <span class="nc">RequestHandlerInterface</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span>
        <span class="kt">ServerRequestInterface</span> <span class="nv">$request</span><span class="p">,</span>
    <span class="p">):</span> <span class="kt">ResponseInterface</span>
    <span class="p">{</span>
        <span class="cd">/**
        * Authentication is performed;
        * not visible in this snippet.
        */</span>

        <span class="nv">$post</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">getBody</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getContents</span><span class="p">();</span>
        <span class="nv">$decoded</span> <span class="o">=</span> <span class="nb">json_decode</span><span class="p">(</span>
            <span class="nv">$post</span><span class="p">,</span>
            <span class="kc">true</span><span class="p">,</span>
            <span class="mi">512</span><span class="p">,</span>
            <span class="no">JSON_THROW_ON_ERROR</span><span class="p">,</span>
        <span class="p">);</span>
        <span class="nv">$subscription</span> <span class="o">=</span> <span class="nb">json_decode</span><span class="p">(</span>
            <span class="nv">$decoded</span><span class="p">[</span><span class="s1">'body'</span><span class="p">],</span>
            <span class="kc">true</span><span class="p">,</span>
            <span class="mi">512</span><span class="p">,</span>
            <span class="no">JSON_THROW_ON_ERROR</span><span class="p">,</span>
        <span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">connection</span><span class="o">-&gt;</span><span class="nf">table</span><span class="p">(</span><span class="s1">'pushSubscriptions'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'endpoint'</span><span class="p">,</span> <span class="nv">$subscription</span><span class="p">[</span><span class="s1">'endpoint'</span><span class="p">])</span>
            <span class="o">-&gt;</span><span class="nf">exists</span><span class="p">()</span>
        <span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="k">new</span> <span class="nc">JsonResponse</span><span class="p">([]);</span>
        <span class="p">}</span>

        <span class="nv">$newUuid</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">uuidRepository</span><span class="o">-&gt;</span><span class="nf">getUuid</span><span class="p">();</span>

        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">connection</span><span class="o">-&gt;</span><span class="nf">table</span><span class="p">(</span><span class="s1">'pushSubscriptions'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">insert</span><span class="p">([</span>
                <span class="s1">'userId'</span> <span class="o">=&gt;</span> <span class="nv">$loggedInDriver</span><span class="o">-&gt;</span><span class="nf">getDriverId</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">toString</span><span class="p">(),</span>
                <span class="s1">'subscriptionId'</span> <span class="o">=&gt;</span> <span class="nv">$newUuid</span><span class="o">-&gt;</span><span class="nf">toString</span><span class="p">(),</span>
                <span class="s1">'subscription'</span> <span class="o">=&gt;</span> <span class="nb">json_encode</span><span class="p">(</span><span class="nv">$subscription</span><span class="p">),</span>
                <span class="s1">'endpoint'</span> <span class="o">=&gt;</span> <span class="nv">$subscription</span><span class="p">[</span><span class="s1">'endpoint'</span><span class="p">],</span>
            <span class="p">])</span>
        <span class="p">;</span>

        <span class="k">return</span> <span class="k">new</span> <span class="nc">JsonResponse</span><span class="p">([]);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Our push server looks like this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">redis</span><span class="p">.</span><span class="nf">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">message</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">channel</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">channel</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">push-notification</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">assert</span><span class="p">(</span><span class="k">typeof</span> <span class="nx">message</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">string</span><span class="dl">'</span><span class="p">)</span>

    <span class="kd">const</span> <span class="nx">parsedMessage</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span> <span class="kd">as </span><span class="p">{</span>
      <span class="na">subscription</span><span class="p">:</span> <span class="nx">webPush</span><span class="p">.</span><span class="nx">PushSubscription</span>
      <span class="na">payload</span><span class="p">:</span> <span class="kr">string</span>
    <span class="p">}</span>

    <span class="nx">webPush</span>
      <span class="p">.</span><span class="nf">sendNotification</span><span class="p">(</span>
        <span class="nx">parsedMessage</span><span class="p">.</span><span class="nx">subscription</span><span class="p">,</span>
        <span class="nx">parsedMessage</span><span class="p">.</span><span class="nx">payload</span><span class="p">,</span>
        <span class="p">{</span>
          <span class="na">TTL</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span>
          <span class="na">urgency</span><span class="p">:</span> <span class="dl">'</span><span class="s1">high</span><span class="dl">'</span><span class="p">,</span>
        <span class="p">}</span>
      <span class="p">)</span>
  <span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Now we can call from anywhere in our backend to send an immediate push
notification to the user’s device:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">redisClient</span><span class="o">-&gt;</span><span class="nf">publish</span><span class="p">(</span>
    <span class="s1">'push-notification'</span><span class="p">,</span>
    <span class="nb">json_encode</span><span class="p">([</span>
        <span class="s1">'subscription'</span> <span class="o">=&gt;</span> <span class="nv">$subscription</span><span class="p">,</span>
        <span class="s1">'payload'</span> <span class="o">=&gt;</span> <span class="nb">json_encode</span><span class="p">([</span>
            <span class="s1">'title'</span> <span class="o">=&gt;</span> <span class="s1">'Hey! You have a new order!'</span><span class="p">,</span>
            <span class="s1">'body'</span> <span class="o">=&gt;</span> <span class="s1">'Be at the payphone in 7 minutes'</span><span class="p">,</span>
        <span class="p">]),</span>
    <span class="p">])</span>
<span class="p">);</span>
</code></pre></div></div>

<p>And finally, our service worker shows the push notification to the user,
even if the app has been closed for days and the screen is locked:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">self</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">push</span><span class="dl">'</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">parsedPayload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span>
      <span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>
    <span class="p">)</span>

    <span class="nx">event</span><span class="p">.</span><span class="nf">waitUntil</span><span class="p">(</span>
      <span class="nb">self</span><span class="p">.</span><span class="nx">registration</span><span class="p">.</span><span class="nf">showNotification</span><span class="p">(</span>
        <span class="nx">parsedPayload</span><span class="p">.</span><span class="nx">title</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">Notification</span><span class="dl">'</span><span class="p">,</span>
        <span class="p">{</span>
          <span class="na">body</span><span class="p">:</span> <span class="nx">parsedPayload</span><span class="p">.</span><span class="nx">body</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">No body</span><span class="dl">'</span><span class="p">,</span>
          <span class="na">data</span><span class="p">:</span> <span class="p">{</span>
            <span class="na">url</span><span class="p">:</span> <span class="nx">parsedPayload</span><span class="p">.</span><span class="nx">url</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">,</span>
          <span class="p">},</span>
        <span class="p">},</span>
      <span class="p">),</span>
    <span class="p">)</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span>
      <span class="dl">'</span><span class="s1">Invalid push payload:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>
    <span class="p">)</span>
  <span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<h2 id="final-word">Final word</h2>

<p>This article likely only scratches the surface of the entire endeavour,
however, I hope that some interesting information was shared. Do not hesitate
to contact me if you have any questions or comments.</p>]]></content><author><name>Frantisek Stanko</name></author><category term="web-development" /><category term="pwa" /><category term="typescript" /><category term="progressive-web-app" /><category term="typescript" /><category term="php" /><category term="websockets" /><category term="offline-first" /><summary type="html"><![CDATA[A detailed look at building a production PWA for a taxi company, covering offline-first architecture, real-time sync, push notifications, and deployment strategies.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://frantisekstanko.com/assets/images/6.jpg" /><media:content medium="image" url="https://frantisekstanko.com/assets/images/6.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>