How to setup Progressive Web App (PWA) on Laravel Vapor

The Problem

When you use Laravel Vapor in your project, all the files you throw in your /public folder is grabbed by the Vapor CLI, Uploaded to S3 and configured to be served via CloudFront.

This is usually fine, but it means that in practice, your files are no longer "on your root domain", they are served via a CloudFlare domain like:

https://d2unr0jlgggwzx.cloudfront.net/ee6d234a-76b6-4907-b5cf-40b1ccbbf9d7/manifest.json

For images, fonts and icons that are used within your application, this is fine.

BUT.

For certain types of files like manifest.json files used by PWAs, ads.txt used by Google Adsense for verification, robots.txt and sometimes sitemaps.xml, it is a hit-and-miss if that will work or not, since you are essentially pointing to a file on an external domain.

If you think about that for a few seconds, it makes sense why that would not work, since if they allowed that to work for things like domain ownership verification etc, it could potentially lead to you proving ownership of someone leses website if you somehow manage to inject some html into a page.

So we have a problem, if you follow any of the million guides on how to setup a PWA, you will most likely run into an issue, where it works fine locally, but when you deploy it to vapor, it will not work, because it can't find the manifest file, because, it is not on your domain anymore.

But surely the Laravel team, thought of this type of use case?

They did, eventually, by adding this option:

https://docs.vapor.build/1.0/projects/deployments.html#assets

However, it has a fatal flaw... I will not work with json files.

Why you ask?, it is because changing this behaviour now, could break existing projects, see this comment for more context.

Anyways, enough preamble, let's do what you came here for, solve the problem.

How to bypass the problem

We can bypass the entire problem by setting up a file proxy within your application.

"What is a file proxy?"

I pretend you ask, knowing that you most likely already know, but needing to move the sentence along into an explanation anyways.

It's when a browser asks your server for a file (like /manifest.json) and instead of a web server (apache, nginx etc) serving the file from the document root, the web application does that job itself, in our case by mapping a route that matches the filename to a controller that will return the file contents with the correct headers.

Routes

Route::get('/manifest.json', [AssetProxyController::class, "manifest"]);
Route::get('/serviceWorker.js', [AssetProxyController::class, "serviceWorker"]);
Route::get('/proxy/{file}', [AssetProxyController::class, "proxyFile"]);

Folder structure:

For reference, this is where the files mentioned in the article is located, It always pissed me off when blogs and even documentation (Looking at you ChartJS documentation!) fail to mention "WHERE" things are located, as if you are magically supposed to know.

So here you go:

routes/web.php
Http/Controllers/AssetProxyController.php
resources/proxy/icon-192x192.png
resources/proxy/icon-256x256.png
resources/proxy/icon-384x384.png
resources/proxy/icon-512x512.png
resources/proxy/manifest.json
resources/proxy/serviceWorker.js

"File Proxy" Controller

Note that we are doing some Str::replace() stuff in here.

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Str;

class AssetProxyController extends Controller
{
    public function manifest()
    {
        return response(file_get_contents(resource_path("proxy/manifest.json")), 200, [
            "Content-Type" => "application/json",
        ]);
    }

    public function serviceWorker()
    {
        $assetRoot = rtrim(asset("/"), "/");

        $content = file_get_contents(resource_path("proxy/serviceWorker.js"));

        // TAKE NOTE OF THIS!!
        // We are replacing the string "[ASSET_ROOT]" in the serviceWorker with the asset() root in our application,
        // in production this will replace
        // [ASSET_ROOT] with https://d2unr0jlgggwzx.cloudfront.net/ee6d234a-76b6-4907-b5cf-40b1ccbbf9d7
        $content = Str::replace("[ASSET_ROOT]", $assetRoot, $content);

        return response($content, 200, [
            "Content-Type" => "text/javascript",
        ]);
    }

    public function proxyFile($filename)
    {
        // Whitelist filenames to prevent access to arbitrary files
        if (! in_array($filename, [
            "icon-192x192.png",
            "icon-256x256.png",
            "icon-384x384.png",
            "icon-512x512.png",
        ])) {
            return abort(404);
        }

        return response(file_get_contents(resource_path("proxy/$filename")), 200, [
            "Content-Type" => "image/png",
        ]);
    }
}

Manifest file

{
  "short_name": "App Name",
  "name": "App Name",
  "description": "Your description here",
  "categories": ["categories", "here"],
  "dir": "ltr",
  "lang": "nn",
  "theme_color": "#be123c",
  "background_color": "#be123c",
  "display": "standalone",
  "scope": "/",
  "id": "/?source=pwa",
  "start_url": "/?source=pwa",
  "icons": [
    {
      "src": "/proxy/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/proxy/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/proxy/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/proxy/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    }
  ]
}

Service Worker

Note that we used [ASSET_ROOT] in this file as a placeholder that will be replaced by the absolute path to our asset root in production.

var staticCachePrefix = 'laravel-pwa'
var staticCacheName = staticCachePrefix + '-v' + new Date().getTime()
var filesToCache = [
  '/offline',
  new Request('[ASSET_ROOT]/css/app.css', { mode: 'no-cors' }),
  new Request('[ASSET_ROOT]/js/app.js', { mode: 'no-cors' }),
  '/proxy/icon-192x192.png',
  '/proxy/icon-256x256.png',
  '/proxy/icon-384x384.png',
  '/proxy/icon-512x512.png',
]

// Cache on install
self.addEventListener('install', (event) => {
  this.skipWaiting()
  event.waitUntil(
    caches
      .open(staticCacheName)
      .then((cache) => {
        return cache.addAll(filesToCache)
      })
      .catch((err) => {
        console.log(err)
      })
  )
})

// Clear cache on activate
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((cacheName) => cacheName.startsWith(staticCachePrefix))
          .filter((cacheName) => cacheName !== staticCacheName)
          .map((cacheName) => caches.delete(cacheName))
      )
    })
  )
})

// Serve from Cache
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches
      .match(event.request)
      .then((response) => {
        return response || fetch(event.request)
      })
      .catch(() => {
        return caches.match('offline')
      })
  )
})

PWA HTML code to add to your pages

<!-- PWA -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="App name" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
  name="apple-mobile-web-app-status-bar-style"
  content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="App name" />
<link rel="manifest" href="/manifest.json" />

<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/serviceWorker.js', { scope: '.' }).then(
      (registration) => console.log('ServiceWorker registration successful'),
      (error) => console.log('ServiceWorker registration failed: ', error)
    )
  }
</script>

Hope you found this useful.

Bonus: PWAs can be listed on Google Play and the App Store

Using PWABuilder, you can package your PWA as a Trusted Web Activity (TWA) and upload them to the Google Play Store, and presumably the App Store.

I did this with Kassalapp and it works great.

Links and references