Progressive Web App Configuration
Overview
This document covers the Progressive Web App (PWA) configuration of the APEX Portal, focusing on the service worker setup, web manifest, and related configurations that enable installability and offline capabilities.
For comprehensive tenant styling and theming integration with PWA configuration, see the Tenant Styling Implementation Guide.
Prerequisites
- .NET 8.0 SDK or later
- Blazor WebAssembly project structure
- Basic understanding of Progressive Web Apps concepts
Configuration Structure
The PWA configuration consists of several key files:
- service-worker.js: The main service worker script
- service-worker.published.js: The service worker script used in published mode
- manifest.webmanifest: The web app manifest defining app metadata
- Apex.Portal.csproj: Project file with ServiceWorker configuration
- index.html: References to the service worker and manifest
API Reference
Project Configuration
In the Apex.Portal.csproj file:
<PropertyGroup>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>
<ItemGroup>
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
</ItemGroup>
Web Manifest Properties
Key properties in manifest.webmanifest:
| Property | Description |
|---|---|
| name | Full name of the application |
| short_name | Short name used on home screens |
| start_url | URL to load when app is launched |
| display | Display mode (standalone, fullscreen, etc.) |
| icons | Array of app icons in various sizes |
| theme_color | Theme color for the application UI |
| background_color | Background color for the splash screen |
Service Worker Registration
In index.html:
<link href="manifest.webmanifest" rel="manifest" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
Examples
Custom Cache Strategy
To modify the caching strategy in the service worker:
// In service-worker.published.js
self.addEventListener('fetch', event => {
if (event.request.method === 'GET') {
// Custom caching strategy here
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request)
.then(response => {
return caches.open('dynamic-cache')
.then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
}
});
Custom Installation Prompt
To add a custom app installation prompt:
// In a separate JS file
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show a custom install button
document.getElementById('installButton').style.display = 'block';
});
document.getElementById('installButton').addEventListener('click', (e) => {
deferredPrompt.prompt();
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the install prompt');
}
deferredPrompt = null;
});
});
HTTP Request Interception and API Routing
The service worker in APEX Portal plays a critical role in intercepting HTTP requests and routing them to the appropriate API backends. This enables efficient caching strategies, offline support, and dynamic backend routing based on configuration.
Service Worker Request Handling Flow
- Request Interception: The service worker intercepts all fetch events (HTTP requests) made by the application
- Request Analysis: It analyzes the request URL to determine its type and destination
- Routing Decision: Based on the URL pattern, it decides how to handle the request (cache, API routing, direct fetch)
- Response Generation: It either returns a cached response or fetches from the network with appropriate routing
API Request Routing
For API requests (those starting with /api/), the service worker implements a sophisticated routing mechanism:
// In service-worker.js
self.addEventListener('fetch', async (event) => {
const requestUrl = new URL(event.request.url);
// If the request is for an API endpoint in the same origin
if (requestUrl.origin === location.origin && requestUrl.pathname.startsWith('/api/')) {
event.respondWith(handleApiRequest(event.request));
}
});
The handleApiRequest function:
- Parses the request URL to extract the API service name
- Loads application settings from
appsettings.json - Looks up the corresponding backend URL for the API service
- Constructs a new request to the actual backend service
- Forwards the request and returns the response
async function handleApiRequest(request) {
const requestUrl = new URL(request.url);
// Load application settings if not already loaded
self.appSettings = self.appSettings || await getAppSettings();
// Extract path segments to determine API service
let pathSegments = requestUrl.pathname.split('/').filter(segment => segment.length > 0);
let newUrl = URL.parse(appSettings.Apis.BaseUrl) ?? undefined;
let path = "./";
if (pathSegments.length > 1) {
// Extract the API service name (e.g., "billing" from "/api/billing/...")
let apiName = pathSegments[1];
// Look up the service configuration in appSettings
const appSettingKey = Object.keys(self.appSettings.Apis)
.find(api => api.toLowerCase() === apiName.toLowerCase());
const apiSettings = appSettingKey ? self.appSettings.Apis[appSettingKey] : null;
if (apiSettings) {
// Use service-specific backend URL
newUrl = URL.parse(apiSettings.BaseUrl, newUrl);
// Remove "api" and service name from path
path = pathSegments.slice(2).join('/');
}
else {
// Use default API URL
path = pathSegments.slice(1).join('/');
console.warn(`API configuration not found for ${apiName}. Using default base URL.`);
}
}
// Construct new URL with proper backend path
newUrl.pathname = newUrl.pathname + path;
// Forward the request to the appropriate backend
let newRequest = new Request(newUrl, request);
return fetch(newRequest);
}
API Configuration
The API routing is configured in appsettings.json:
{
"Apis": {
"BaseUrl": "https://apis.lightstone.co.za/",
"Billing": {
"BaseUrl": "http://localhost:49954/"
}
}
}
With this configuration:
- Requests to
/api/billing/...are routed tohttp://localhost:49954/... - Requests to other API services (e.g.,
/api/users/...) are routed tohttps://apis.lightstone.co.za/users/...
Development vs. Production Behavior
- Development Mode: The service worker does minimal caching and focuses on API routing to facilitate development
- Production Mode: The service worker (
service-worker.published.js) implements both caching and API routing for optimal performance
Cache Management for API Responses
For optimal performance, the service worker can be configured to cache API responses with various strategies:
async function handleApiRequest(request) {
// ...existing routing code...
// For GET requests, implement cache-first strategy
if (request.method === 'GET') {
const cache = await caches.open('api-cache');
const cachedResponse = await cache.match(newRequest);
if (cachedResponse) {
return cachedResponse;
}
// If not in cache, fetch from network and cache
const networkResponse = await fetch(newRequest);
// Only cache successful responses
if (networkResponse.ok) {
cache.put(newRequest, networkResponse.clone());
}
return networkResponse;
}
// For non-GET requests, always go to network
return fetch(newRequest);
}
Best Practices for API Request Handling
- Use appropriate cache strategies based on the API endpoint's data volatility
- Implement cache invalidation for APIs with frequently changing data
- Consider using the Cache API's expiration features for time-sensitive data
- Implement fallback mechanisms for offline scenarios
- Add proper error handling for failed API requests
Limitations and Improvements
Current Limitations
The current implementation of service worker API routing in the APEX Portal has some limitations:
-
Static Configuration: The service worker reads the API endpoint configurations directly from the
appsettings.jsonfile, which means:- Settings must be embedded in the published files
- Different environments (Development, Staging, Production) require separate deployments with replaced settings files
- Configuration changes require a new deployment of the entire application
-
Limited Runtime Configurability: There's no way to dynamically update API endpoints without replacing the configuration files and triggering a service worker update.
-
Lack of Integration with Blazor: The service worker operates independently from the Blazor application, with no direct communication channel between them.
-
Manual Management: Any changes to API routing logic require modifying and testing the service worker JavaScript files directly.
Using Blazor.ServiceWorker Library
The Blazor.ServiceWorker library offers a promising solution to address these limitations. This library provides a C# API for interacting with service workers in Blazor applications.
Benefits of Using Blazor.ServiceWorker
-
Dynamic Configuration: API endpoint configurations could be fetched at runtime from a central configuration service and communicated to the service worker.
-
Environment Awareness: The Blazor application can determine the environment it's running in and configure the service worker appropriately.
-
Two-way Communication: The library enables message passing between the Blazor application and service worker:
// From Blazor to service worker
await serviceWorker.Controller.PostMessageAsync(new
{
type = "updateApiConfig",
config = new Dictionary<string, string>
{
["Billing"] = "https://billing-api.production.apex.com/"
}
}); -
Type-Safe API: Work with service workers using C# types instead of raw JavaScript:
@inject IServiceWorkerContainer ServiceWorkerContainer
@code {
private async Task RegisterServiceWorker()
{
var registration = await ServiceWorkerContainer.RegisterAsync(
"./service-worker.js",
new ServiceWorkerRegistrationOptions { Scope = "./" });
}
}
Implementation Approach
To implement a more flexible solution using Blazor.ServiceWorker:
-
Install the Package:
dotnet add package KristofferStrube.Blazor.ServiceWorker -
Register the Service:
// In Program.cs
builder.Services.AddServiceWorkerContainer(); -
Modify Service Worker Registration:
// In a startup service
public class ServiceWorkerConfigService
{
private readonly IServiceWorkerContainer _serviceWorkerContainer;
private readonly IConfiguration _configuration;
public ServiceWorkerConfigService(
IServiceWorkerContainer serviceWorkerContainer,
IConfiguration configuration)
{
_serviceWorkerContainer = serviceWorkerContainer;
_configuration = configuration;
}
public async Task InitializeAsync()
{
var registration = await _serviceWorkerContainer.RegisterAsync("./service-worker.js");
if (registration.Active is not null)
{
// Send configuration to service worker
var apiConfig = _configuration.GetSection("Apis").Get<Dictionary<string, object>>();
await registration.Active.PostMessageAsync(new
{
type = "configureApis",
config = apiConfig
});
}
}
} -
Update the Service Worker:
// In service-worker.js
let apiConfiguration = {};
// Listen for messages from the Blazor application
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'configureApis') {
apiConfiguration = event.data.config;
console.log('Service worker received API configuration', apiConfiguration);
}
});
// Use the dynamic configuration in the request handler
async function handleApiRequest(request) {
// Instead of reading from appsettings.json, use the dynamically provided configuration
const requestUrl = new URL(request.url);
let pathSegments = requestUrl.pathname.split('/').filter(segment => segment.length > 0);
if (pathSegments.length > 1) {
let apiName = pathSegments[1];
const apiSettings = apiConfiguration[apiName];
// Continue with request handling using the dynamic configuration...
}
}
Advantages of This Approach
-
Environment-Specific Configuration: The Blazor application can load environment-specific configuration and pass it to the service worker.
-
Runtime Updates: API endpoints can be updated without requiring a new deployment or service worker reinstallation.
-
Centralized Configuration: All configuration can be managed in one place, following the same patterns as other parts of the application.
-
Enhanced Debuggability: C# interfaces provide better typing and debugging support than raw JavaScript.
This approach eliminates the need to replace configuration files for each environment and provides a more maintainable and flexible solution for managing API endpoint routing in the service worker.
Best Practices
- Test the offline experience thoroughly
- Optimize the size of cached resources
- Include appropriate app icons in various sizes
- Set meaningful app name and short name
- Use the appropriate display mode for your app
- Consider versioning your cache to facilitate updates
- Test the PWA on multiple devices and browsers
Common Issues and Solutions
Issue: Service worker not registering
Solution: Ensure the service worker file is properly referenced in the project and included in the build output.
Issue: Updates not being applied
Solution: Implement a cache versioning strategy and check for updates on app start.
Issue: Install prompt not showing
Solution: Ensure the app meets all the installability criteria, including serving over HTTPS and having a valid manifest.