Skip to main content

11 · PWA & Embedding

Purpose: Specify @rasd/pwa (service-worker helpers, update/install/persistence hooks) and the four ways a host embeds Rasd Forms on the web (npm React package, script-tag/CDN bundle, <rasd-form> Web Component, iframe), including CSP, SSR, size and security constraints. Audience: Engineers building @rasd/pwa and @rasd/element; developers at UN/NGO organisations integrating Rasd Forms into an existing PWA or website.

TL;DR

  • The library never owns the host's service worker. It never registers, updates, skipWaitings or reloads. @rasd/pwa contributes routes, plugins and hooks to a host-owned Workbox 7.4.x / Serwist 9.x worker (ADR-13 in 03 · Architecture).
  • Runtime caches are Rasd-namespaced and versioned (rasd-defs-v1, rasd-media-v1, rasd-fonts-v1); RSP endpoints (/v1/*) are NetworkOnly because the outbox in IndexedDB — not the SW — is the source of truth for sync.
  • Background Sync is a Chromium-only accelerator; RasdOutboxPlugin only wakes the sync engine, it never replays requests itself.
  • Update UX is waiting → prompt → SKIP_WAITING → controlling → reload, and useServiceWorkerUpdate() refuses to reload while useRasdBusy() is true. Cached JS may be older than stored data: storage schemas are forward-only and older code opens a newer database read-only.
  • Enumerators must be installed (Home Screen) on iOS to escape the 7-day ITP purge; call navigator.storage.persist() after the first successful sync, and show a low-storage banner below 100 MB headroom.
  • Embedding order of preference for offline field work: npm React package → <rasd-form> custom element (same-origin, Shadow DOM) → self-hosted IIFE. Cross-origin iframes are online-only (partitioned storage, 10 % Safari quota).
  • CSP: no eval, worker-src 'self', blob: for media, nonce for injected styles, SRI for CDN builds. The license token's apps[] claim binds a public token to allowed origins.

1. Responsibility split

Concern@rasd/pwa providesHost app owns
Service-worker file & registrationRoute/plugin factories, a prebuilt rasd-sw.js for importScripts, CLI copy of a narrow-scope fallback workersw.ts/sw.js, navigator.serviceWorker.register(), scope, precache manifest, cleanupOutdatedCaches
App-shell precacheNothing (adds no entries to the host manifest)Bundler plugin (vite-plugin-pwa, Serwist, workbox-cli)
Runtime caching of form assetsregisterRasdRoutes(), rasdRuntimeCaching(), precacheForms()Which origins are allowed, cache versions of their assets
SyncBackground-Sync wake bridge (RasdOutboxPlugin + connectBackgroundSync())Nothing — the outbox lives in @rasd/sync
Update UXuseServiceWorkerUpdate() (state + guarded applyUpdate())Prompt UI, deciding when to call applyUpdate()
InstalluseInstallPrompt(), <InstallHint> strings (en/ar/fr, RTL-aware)Placement, manifest, icons
Storage durabilityuseStoragePersistence(), requestPersistence()Diagnostics screen, retention policy
Manifest / offline pageDocumented templatesThe files themselves

Everything in @rasd/pwa is optional: a host with no service worker still gets full offline forms via @rasd/storage-dexie (autosaved drafts, outbox) — it only loses cached media/fonts and installability.

2. Package surface

// @rasd/pwa (window context; React hooks + plain functions)
export function useServiceWorkerUpdate(opts?: SwUpdateOptions): SwUpdateState; // no opts → navigator.serviceWorker.getRegistration()
export function useInstallPrompt(): InstallPromptState;
export function useStoragePersistence(): { persisted: boolean | null; usageBytes: number; quotaBytes: number | null; headroomBytes: number; low: boolean; request(): Promise<boolean> };
export function requestPersistence(): Promise<boolean>; // idempotent, safe to call often
export function precacheForms(opts: PrecacheFormsOptions): Promise<PrecacheReport>;
export function connectBackgroundSync(opts: { sync: SyncEngine; registration: ServiceWorkerRegistration | Promise<ServiceWorkerRegistration> }): () => void;
export function isStandalone(): boolean; // display-mode standalone || navigator.standalone
export function InstallHint(props: { locale?: string; onDismiss?(): void }): JSX.Element;

// @rasd/pwa/sw (service-worker context; imports workbox-routing/strategies/expiration/cacheable-response/background-sync)
export function registerRasdRoutes(opts: RasdRouteOptions): void; // injectManifest / hand-written Workbox worker
export function rasdSerwistRoutes(opts: RasdRouteOptions): SerwistRuntimeCaching[]; // { matcher, handler: Strategy } for Serwist
export class RasdOutboxPlugin { constructor(opts?: { tag?: string }) } // 'rasd-outbox' default

// @rasd/pwa/workbox-config (Node or config files; data only, no workbox imports)
export function rasdRuntimeCaching(opts: RasdRouteOptions): RuntimeCaching[]; // { urlPattern, handler: 'CacheFirst'|…, options } for generateSW / vite-plugin-pwa / workbox-cli
export function rasdWorkboxConfig(opts: RasdRouteOptions): Partial<GenerateSWOptions>; // runtimeCaching + importScripts:['rasd-sw.js'] + navigateFallbackDenylist

// static asset: node_modules/@rasd/pwa/dist/rasd-sw.js (IIFE, self-contained, for importScripts / narrow-scope fallback)
interface RasdRouteOptions {
apiOrigin: string | string[]; // RSP base origin(s) — always NetworkOnly
definitionUrls?: RegExp | ((url: URL) => boolean); // static RFD/dataset JSON fetched by URL (default: *.form.json, *.dataset.json on same origin)
mediaOrigins?: string[]; // question media (default: same origin + apiOrigin attachment paths)
fontOrigins?: string[]; // theme fonts (default: same origin)
cacheVersion?: number; // default 1 → cache names rasd-*-v1
limits?: {
defsMaxEntries?: number; // default 200
mediaMaxEntries?: number; // default 500
mediaMaxAgeSeconds?: number; // default 2_592_000 (30 d)
fontsMaxAgeSeconds?: number; // default 31_536_000 (365 d)
};
}

The sub-path split is deliberate: @rasd/pwa (React, window) must never be imported into a worker, and @rasd/pwa/sw (self.registration) never into the page. Both are ESM with sideEffects: false.

3. Caching design (Workbox 7 recipes)

Facts grounding this section: workbox-* 7.4.1 (2026-05-04, MIT, maintenance mode); workbox-build defaults skipWaiting:false, clientsClaim:false, cleanupOutdatedCaches:false, maximumFileSizeToCacheInBytes 2 MiB (research/05 §1–2).

3.1 Cache matrix

CacheWhatStrategyPlugins / limitsNotes
host precacheApp shell built by the hostPrecachehost's cleanupOutdatedCaches()Rasd chunks must each stay < 2 MiB uncompressed; @rasd/builder is a lazy chunk the field runner never precaches
rasd-defs-v1URL-addressed RFD JSON (definition-url, datasets[].source:"url", *.form.json)StaleWhileRevalidateExpirationPlugin({ maxEntries: 200 }), CacheableResponsePlugin({ statuses: [0, 200] })RSP-pulled definitions are not here — the sync engine stores them in storage.forms
rasd-media-v1element.media.image/audio, choice images, attachment previews from the serverCacheFirstExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 2_592_000 /* 30 d */, purgeOnQuotaError: true }), CacheableResponsePlugin({ statuses: [0, 200] })Opaque (status 0) allowed for cross-origin CDNs; range requests supported for audio
rasd-fonts-v1Theme WOFF2 (incl. default OFL Arabic family)CacheFirstExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 31_536_000 /* 365 d */ })Self-hosted only; never Google Fonts at runtime
RSP /v1/* (forms pull, datasets delta, submissions, tus attachments, events)NetworkOnlyRasdOutboxPlugin on POST /v1/submissions:batch (wake only)Delta responses depend on since cursors; caching them would corrupt sync. Also excluded from navigateFallback

The Vary: Origin trap

Any cache entry a host precaches for the app shell must be matched with { ignoreVary: true }. cache.add() stores a response under a request the Cache API normalises to no-cors, which carries no Origin header, while a module script — <script type="module"> and every static import it chains to — is fetched in cors mode, which does send one. A host that answers with Vary: Origin (Vite's own preview server does, as does nearly every CORS-enabled static host) therefore makes its own precached shell unmatchable: cache.match() compares the Origin header, sees absent vs present, and misses. A CacheFirst handler then falls through to fetch(), which offline rejects — so a fully precached app paints a blank page while reporting every asset as a network error, and the service worker looks like it is working because the navigation itself is served from cache.

Ignoring Vary is correct for the shell and for fonts, and only there: those URLs are content-hashed or immutable, so one URL addresses exactly one body and there is no second variant a Vary header could be selecting between. The rasd-defs-v1 caches deliberately keep the default semantics — Vary: Accept-Language on a form definition is a real distinction, and ignoring it would hand an Arabic enumerator the English form. rasdRuntimeCaching() sets options.matchOptions.ignoreVary on the fonts entry for exactly this reason; a host writing its own worker must do the same for its shell lookups.

Cache versioning is independent of the host precache: bumping cacheVersion (or a Rasd minor release that changes cache layout) creates rasd-*-v2 and deletes rasd-*-v1 on activate; the host's caches are never touched. purgeOnQuotaError lets Workbox drop Rasd media first under quota pressure — media is re-fetchable, submissions are not.

3.2 injectManifest worker (Vite / plain Workbox)

// src/sw.ts — host-owned
/// <reference lib="webworker" />
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRasdRoutes } from '@rasd/pwa/sw';
declare const self: ServiceWorkerGlobalScope;

precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
registerRasdRoutes({ apiOrigin: 'https://api.moda.example.org', mediaOrigins: ['https://cdn.moda.example.org'] });

self.addEventListener('message', (e) => {
if (e.data?.type === 'SKIP_WAITING') self.skipWaiting(); // only ever triggered by the host's applyUpdate()
});

3.3 generateSW (vite-plugin-pwa 1.3.x, Vite 3–8)

// vite.config.ts
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import { rasdRuntimeCaching } from '@rasd/pwa/workbox-config';
export default defineConfig({
plugins: [VitePWA({
registerType: 'prompt', // never 'autoUpdate' for field apps
workbox: {
runtimeCaching: rasdRuntimeCaching({ apiOrigin: 'https://api.moda.example.org' }),
cleanupOutdatedCaches: true,
navigateFallbackDenylist: [/^\/v1\//],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
},
manifest: { /* §8 */ },
})],
});

rasdRuntimeCaching() returns plain { urlPattern, handler, options } entries (string handler names, no Workbox imports) that workbox-build, vite-plugin-pwa and workbox-cli accept; rasdSerwistRoutes() returns { matcher, handler } entries with strategy instances for Serwist's runtimeCaching. All three entry points share one route table so behaviour is identical.

3.4 Next.js App Router with Serwist 9.x

next-pwa and @ducanh2912/next-pwa are unmaintained and unsupported (research/05 §1). Use @serwist/next (webpack) or @serwist/turbopack (Next 15/16 default bundler; builds sw.ts from app/serwist/[path]/route.ts).

// app/sw.ts
import { defaultCache } from '@serwist/next/worker';
import { Serwist, type PrecacheEntry } from 'serwist';
import { rasdSerwistRoutes } from '@rasd/pwa/sw';
declare const self: ServiceWorkerGlobalScope & { __SW_MANIFEST: (string | PrecacheEntry)[] };

const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: false, clientsClaim: false, navigationPreload: true,
runtimeCaching: [...rasdSerwistRoutes({ apiOrigin: process.env.NEXT_PUBLIC_RSP_ORIGIN! }), ...defaultCache], // Rasd routes first so /v1/* stays NetworkOnly
fallbacks: { entries: [{ url: '/~offline', matcher: ({ request }) => request.destination === 'document' }] },
});
serwist.addEventListeners();

3.5 No bundler: workbox-cli and Expo web

Expo web (Metro) has no first-class SW support; the documented recipe is expo export -p web then npx workbox-cli generateSW workbox-config.js (research/05 §1, §11). Ship:

// workbox-config.js
const { rasdWorkboxConfig } = require('@rasd/pwa/workbox-config');
module.exports = { globDirectory: 'dist', globPatterns: ['**/*.{js,css,html,woff2,png,svg}'], swDest: 'dist/sw.js',
...rasdWorkboxConfig({ apiOrigin: 'https://api.moda.example.org' }) }; // adds runtimeCaching + importScripts: ['rasd-sw.js']

rasd-sw.js is copied by rasd pwa init dist/ (also public/) — a @rasd/cli sub-command this document adds to the list in 21 §1, following the npx msw init <publicDir> pattern. Because CSP worker-src/script-src applies inside workers and a SW must be same-origin, Rasd never hosts a worker on a CDN — the file is always an npm asset the host serves.

3.6 Fallback when the host has no service worker at all

rasd pwa init public/ --scope /rasd/ copies rasd-sw.js and prints the one-liner navigator.serviceWorker.register('/rasd/rasd-sw.js', { scope: '/rasd/' }). A narrow scope gives Background Sync and future push without hijacking the host's navigations (OneSignal's separate-scope pattern). Document the migration rule verbatim: once the host later merges into its own worker, keep serving the old file (returning a self-unregistering stub) for about a year while installed clients re-register.

3.7 precacheForms() (window side)

Warms rasd-defs-v1/rasd-media-v1/rasd-fonts-v1 from the page — typically after login, on "Download for offline", or when the sync engine emits formUpdated.

const report = await precacheForms({
definitions: await storage.forms.listLatest(), // walks element.media.*, choice images, datasets[].source:'url', theme fonts
concurrency: 4, maxBytes: 200 * 1024 * 1024, // stop and report when the estimated total exceeds the budget
onProgress: ({ done, total, bytes }) => setProgress(done / total),
});
// { cached: 312, skipped: 40, failed: [{ url, status }], bytes: 48_211_990 }

Rules: idempotent (cache.match() before fetch); uses cache.put() on 200 only; failures never throw — the form still renders with a broken-image placeholder offline; abort via AbortSignal; requires the SW caches only for reads, so it also works with no SW (the Cache API is available in windows).

3.8 Offline fallback route

The SPA shell is the host's; Rasd ships no fallback page. Recommendation: precache a self-contained offline.html (inline CSS, no JS), serve it from the navigation catch handler (network errors only — 4xx/5xx never reach catch()), and render <RasdOfflineFormList> (forms and drafts from IndexedDB) on the app's real routes rather than relying on the fallback.

4. Background Sync bridge

Support: Chromium only — Chrome 49+, Edge 79+, Samsung Internet 5+, 76.7 % global; no Safari through 26.5, no Firefox through 153; Periodic Background Sync needs an installed Chromium PWA with engagement (research/05 §3). Workbox's BackgroundSyncPlugin queues on fetchDidFail only for network exceptions, not 4xx/5xx, and replays only "whenever the SW starts" where the API is missing. Two engines queuing the same submissions would create duplicates and split the audit trail, so:

  • The outbox in StorageAdapter.outbox is the only queue. RasdOutboxPlugin never stores requests.
  • connectBackgroundSync({ sync, registration }) calls registration.sync.register('rasd-outbox') whenever a submission enters queued while offline or a batch fails with a network error, and listens for { type: 'rasd:sync-wake' } messages → sync.syncNow().
  • In the worker, RasdOutboxPlugin handles the sync event: post rasd:sync-wake to every window client; if there is no client, reject (keeps the tag pending so Chromium retries with its own backoff) unless event.lastChance, then resolve. Auth is host-delegated (getAuthToken() lives in the page), so the worker cannot drain the outbox itself.
  • Periodic sync (periodicSync.register('rasd-forms-pull', { minInterval: 12 * 60 * 60 * 1000 })) is registered only when permission is granted; the handler again only wakes a client.
sequenceDiagram
participant P as Page · SyncEngine
participant SW as Service worker · RasdOutboxPlugin
participant OS as Browser sync scheduler
P->>P: finalize, then enqueue to outbox while offline
P->>SW: registration.sync.register with tag rasd-outbox
OS-->>SW: sync event when connectivity returns
alt window client open
SW->>P: postMessage type rasd:sync-wake
P->>P: syncNow drains outbox to RSP
else no client
SW-->>OS: reject to retry later, resolve if lastChance
end

5. Service-worker lifecycle and update UX

5.1 useServiceWorkerUpdate()

interface SwUpdateOptions {
registration?: ServiceWorkerRegistration | Promise<ServiceWorkerRegistration | undefined>; // or:
workbox?: Workbox; // workbox-window instance (uses 'waiting'/'controlling'/isUpdate)
vitePwa?: { needRefresh: boolean; updateServiceWorker(reload?: boolean): Promise<void> }; // from virtual:pwa-register/react
mode?: 'prompt' | 'auto'; // default 'prompt'
checkIntervalMs?: number; // default 3_600_000 (1 h) → registration.update()
deferWhileBusy?: boolean; // default true — gate on useRasdBusy()
}
interface SwUpdateState {
updateAvailable: boolean; offlineReady: boolean; busy: boolean; deferred: boolean;
applyUpdate(opts?: { force?: boolean }): Promise<'applied' | 'deferred'>; // posts SKIP_WAITING, reloads on 'controlling'
dismiss(): void;
}

Semantics: the hook never posts SKIP_WAITING or reloads on its own initiative — only inside applyUpdate(), which the host calls from its prompt. mode: 'auto' calls applyUpdate() as soon as an update is waiting and busy is false; if busy, it waits and applies when the busy signal clears (typically the list screen after finalize), or on the next visibilitychange: hidden. applyUpdate() returns 'deferred' and sets deferred: true while useRasdBusy() (dirty draft, save or finalize in flight — 06 §4) is true, unless force: true. Blind skipWaiting is discouraged by Workbox because lazily loaded, hashed chunks may no longer exist in the new precache — a mid-form update is a data-loss event and a RASD_SW_UPDATE_WHILE_BUSY console warning is emitted when force is used.

stateDiagram-v2
[*] --> idle
idle --> waiting: SW waiting, isUpdate
waiting --> prompted: mode prompt, host shows banner
waiting --> applying: mode auto and not busy
prompted --> deferred: applyUpdate while busy
deferred --> applying: busy becomes false, or force
prompted --> applying: applyUpdate and not busy
applying --> reload: SKIP_WAITING then controlling
reload --> [*]

Host recipe (vite-plugin-pwa registerType: 'prompt'):

import { useRegisterSW } from 'virtual:pwa-register/react';
import { useServiceWorkerUpdate } from '@rasd/pwa';

const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW();
const sw = useServiceWorkerUpdate({ vitePwa: { needRefresh, updateServiceWorker } });
{sw.updateAvailable && <UpdateBanner disabled={sw.busy} onClick={() => sw.applyUpdate()} />}

5.2 Version skew: cached JS vs stored data

After a deploy, a device can run older JS (SW-precached shell) against newer data (written by another tab that already updated, or by a newer version before a rollback), or the reverse. Policy:

SituationBehaviour
Newer code, older DB@rasd/storage migrations run forward in open() (Dexie versioned schema; SQLite user_version); migrations are additive and idempotent; a failed migration throws RASD_STORAGE_MIGRATION and the app shows the recovery card with Export
Older code, newer DBDexie throws VersionError; the adapter maps it to RASD_STORAGE_DOWNGRADE (09 · Offline storage) and reopens read-only (drafts visible and exportable via the export() path, no writes, banner "Update the app to continue"); the host should call applyUpdate({ force: false }) when not busy
Older code, newer RFD (rasd MINOR ahead)Ignore-and-preserve unknown properties within a MAJOR (spine §4.3b); unknown element types render the placeholder
Older code, RFD MAJOR aheadRASD_SCHEMA_INVALID with details.reason: 'majorAhead'; the form is listed but not openable
Two tabs, different versionsWeb Locks leader syncs; the older tab receives BroadcastChannel change events it may not understand → it reloads itself only if not busy (this reload is the host's shell responding to RASD_STORAGE_DOWNGRADE, not Rasd)

Storage schema versions are never bumped in a patch release; a minor release may add tables/indexes only; a major release may run data rewrites and ships a converter.

6. Install prompt

interface InstallPromptState {
canPrompt: boolean; // Chromium: beforeinstallprompt captured and not yet used
platform: 'chromium' | 'ios-safari' | 'other';
installed: boolean; // display-mode standalone || navigator.standalone || appinstalled fired
prompt(): Promise<'accepted' | 'dismissed' | 'unavailable'>;
}
  • Chromium: preventDefault() on beforeinstallprompt, keep the event, expose prompt(), read userChoice, listen to appinstalled. Chrome no longer requires a SW fetch handler to install (108 Android / 112 desktop); the richer install sheet needs description + screenshots (research/05 §5).
  • iOS: no beforeinstallprompt. <InstallHint> renders Share → "Add to Home Screen" steps with the iOS 26 note (since Sept 2025 any site added to the Home Screen opens as a web app by default, manifest optional). Copy is provided in en/ar/fr and mirrors the share icon under dir="rtl".
  • Show the hint after the first successful sync or after 2 sessions, not on first paint; suppress when installed; persist dismissal in storage.kv (rasd.pwa.installHintDismissedAt) for 14 days.

7. Storage persistence and eviction

Facts: Chromium grants up to 60 % of disk per origin and evicts best-effort origins LRU above 80 %; Safari 17+ ≈ 60 % per origin in the browser, 15 % inside other apps' WKWebViews, 10 % of the parent quota for cross-origin frames; ITP purges all script-writable storage after 7 days of Safari use without interaction unless installed to the Home Screen; persist() is silent in Chromium/Safari and prompts in Firefox (research/04 §1.3, research/05 §6).

RuleValue
When to call requestPersistence()after first successful sync, after install (appinstalled), and again on every app start until persisted === true (Chrome grants by engagement, so retrying helps)
Low-storage bannerthe on('quota') warning edge from 09 §4: headroomBytes below whichever is smaller of 100 MB and 10 % of the quota; text explains "photos may not save"; offers Sync now + Free space (purge synced attachments older than retention.purgeSyncedAfterDays)
Criticalthe on('quota') critical edge, headroomBytes < 20 MB: attachments.put refuses with RASD_STORAGE_QUOTA, so all new capture is blocked (image, audio, video, signature, file) while submissions.patch is still attempted — text answers keep saving and finalize still works (09 §4, 14 §10)
Quota errorsany QuotaExceededError maps to RASD_STORAGE_QUOTA; the renderer keeps the in-memory draft, shows "cannot save offline", retries the write after the user frees space — never crashes
Safari tab (not installed)show the 7-day warning when platform === 'ios-safari' && !installed && pendingCount > 0; sync aggressively (every foreground)
Private modedetect via reduced quota / OPFS absence; warn that data may be wiped when the window closes
Diagnosticshost "About/Diagnostics" screen shows estimate(), persisted, isStandalone(), SW state, cache sizes, pending counts, last sync

Never store submissions in localStorage (5 MiB, synchronous, not durable). Eviction is all-or-nothing per origin, so installation and early sync are safety features, not growth features.

8. Manifest recommendations

{
"id": "/", "name": "MODA Field Monitoring", "short_name": "MODA", "lang": "ar", "dir": "rtl",
"start_url": "/?source=pwa", "scope": "/", "display": "standalone", "orientation": "any",
"background_color": "#ffffff", "theme_color": "#0B6EFD",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"description": "Offline post-distribution monitoring forms",
"screenshots": [{ "src": "/screens/form.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }],
"prefer_related_applications": false
}

Rasd documents this template; the host's build plugin generates the file. theme_color should equal the theme's color.primary for the active mode; keep start_url inside scope; do not include related_applications unless a store app truly supersedes the PWA.

9. iOS / Safari constraints

CapabilitySafari / iOS statusRasd behaviour
Background Sync / Periodic SyncNot supported (through Safari 26.5)Foreground triggers only; connectBackgroundSync no-ops
Web PushHome-Screen web apps only (iOS 16.4+); Declarative Web Push from iOS 18.4 needs no SWOptional module; send declarative payloads; gate on installed
Storage quota~60 % per origin (browser), 15 % in WKWebView, 10 % for cross-origin framesFine for surveys; iframe embeds are online-only
7-day ITP purgeApplies to Safari tabs; Home-Screen apps exemptInstall hint + warning + aggressive sync
navigator.storage.persist()15.2+, heuristic (installed apps favoured)Retry on each start; show status
Camera / geolocation in standalonegetUserMedia, <input capture> and geolocation work in Home-Screen apps; permissions are re-asked per app; no background location@rasd/media falls back to <input type="file" capture>; geopoint is foreground only
beforeinstallpromptAbsentiOS instructions; iOS 26 makes the manifest optional and supports SVG icons — still ship PNG 192/512 for older iOS
WKWebView (Capacitor/Cordova)Reduced quota, capacitor://localhost originPrefer @rasd/native; if web, list the WebView origin in apps[]

10. PWA quality checklist (Lighthouse has no PWA category)

Lighthouse 12 removed the PWA category (PSI switched 2024-05-10), so "PWA score" is not a target (research/05 §8). rasd doctor --pwa <url> (Playwright-driven) checks the list below and prints a report; it is the same rasd doctor command that prints storage estimate, persistence, migration state, license state and outbox age (03 §10, 21 §1), not a second tool. The list is also the acceptance bar for host recipes.

  • Served over HTTPS; manifest with name, short_name, id, start_url, display, 192/512 + maskable icons.
  • Airplane-mode reload renders the shell, the assigned forms, images and the Arabic font.
  • rasd-defs-v1, rasd-media-v1, rasd-fonts-v1 present; /v1/* never appears in any cache.
  • navigator.storage.persisted() true after first sync (Chromium), or the diagnostics screen shows why not.
  • Update prompt appears on new deploy and is deferred while a draft is dirty; no reload without host action.
  • Zero requests to non-host origins during a full capture→sync session (INV-5, 02 · Requirements).
  • Every JS chunk < 2 MiB uncompressed; runner ≤ 120 kB gz excluding React.
  • <InstallHint> shown on iOS Safari when not installed and pending submissions exist.

11. Embedding options

flowchart TD
A["Host stack?"] -->|React / Next / RN-web| B["@rasd/react npm package"]
A -->|Vue / Angular / Rails / plain HTML| C{"Can it install npm and bundle?"}
C -->|yes| D["@rasd/element npm package: rasd-form custom element"]
C -->|no| E["self-hosted rasd-forms.iife.js: rasd-form element + window.Rasd"]
A -->|Cannot host code, respondents online| F["Hosted iframe: phase 3, online-only"]
ModelCSS isolationSame-origin storage & SWReact duplicationCSP frictionOffline field use
npm React packagenone (opt-in Shadow via @rasd/element wrapper)yesnonelowestprimary
Script tag / IIFEShadow DOM inside bundleyesbundles React (~45 kB gz)needs script-src host or nonce; SRIyes when self-hosted
<rasd-form> Web Componentstrongyesnone if the host externalises React, else bundledstyle injection must be nonce'dyes
iframetotalpartitioned (Chrome 115+), 10 % Safari quota, no host SWnoneframe-src / frame-ancestorsno — online only

11.1 npm package (React)

import { RasdProvider, FormRenderer } from '@rasd/react';
import { createDexieStorage } from '@rasd/storage-dexie';
import '@rasd/react/styles.css';
<RasdProvider storage={() => createDexieStorage({ namespace: 'moda' })} license={license} theme="rasd-field" locale="ar" sync={sync}>
<FormRenderer definition={def} submissionId={id} onFinalize={(s) => navigate('/list')} />
</RasdProvider>

Pros: smallest bundle, tree-shaking, one React, full hooks API, host CSS layer wins (@layer rasd). Cons: requires a React build. This is the path for 06 · Renderer (React).

11.2 Script tag / CDN bundle (window.Rasd)

<script src="/vendor/rasd/1.4.2/rasd-forms.iife.js"
integrity="sha384-…" crossorigin="anonymous"
data-license="eyJhbGciOiJFZERTQSIs…" data-api="https://api.moda.example.org" data-locale="ar"></script>
<rasd-form definition-url="/forms/pdm-gfd-2026.form.json" locale="ar" dir="rtl"></rasd-form>
<script>
Rasd.ready.then(() => document.querySelector('rasd-form').addEventListener('rasd:finalize', e => console.log(e.detail.submission.id)));
</script>
  • The IIFE bundles React 19 and @rasd/react + @rasd/storage-dexie + @rasd/sync + @rasd/license, defines <rasd-form>, and exposes window.Rasd = { version, ready, render(target, options), defineElement, createTheme, registerFunction, createDexieStorage, createSyncEngine, createLicense }. Rasd.render(el, { definition, … }) is the programmatic equivalent of the element.
  • Versioned URLs only (/rasd/1.4.2/…, never latest) so precache revisions and SRI hashes are stable; SRI.json with sha384 per file is published with every release and printed in release notes.
  • Self-hosting is the production recommendation: it satisfies script-src 'self', avoids a vendor origin in enumerator traffic (INV-5), and lets the host precache the bundle. cdn.rasd.dev exists for prototyping and CodePen-style demos.
  • Config precedence: element attributes → data-* on document.currentScriptRasd.configure({...}).

11.3 Web Component <rasd-form> (Shadow DOM)

Attributes (strings): definition-url, definition-id (load from storage), submission-id, locale, dir, theme (theme id or JSON URL), license, api (RSP base URL), namespace, read-only, validate-on, css-nonce. Properties (objects, set via JS or by React 19 which maps object props to properties): definition, theme, initialData, registry, storage, sync, license. Methods: validate(), finalize(), getData(), setValue(path, v), getSubmission(). Events (all bubbles: true, composed: true): rasd:ready, rasd:change ({ path, value, data }), rasd:save ({ submission }), rasd:finalize ({ submission }), rasd:error ({ error: RasdError }), rasd:sync ({ pending, lastSyncedAt }). The full signature table is 17 §4; defineRasdElement(tag = 'rasd-form') registers the element under a different tag when the host already owns that name.

Implementation: attachShadow({ mode: 'open' }), createRoot(shadowRoot), CSS injected inside the shadow root as a constructed stylesheet (adoptedStyleSheets) with <style nonce> fallback, RasdProvider portalContainer={shadowRoot} so date pickers, dropdowns and toasts render inside the root and keep their styles; dir/lang on the provider root; document.activeElement returns the host — use shadowRoot.activeElement for focus management. The element is form-associated (static formAssociated = true) and exposes finalized JSON via ElementInternals.setFormValue(), so a plain <form> can submit it. Elements upgrade on the client only; set rasd-form { display:block; min-height:60vh } in host CSS to avoid layout shift.

Theming through the boundary: inside the shadow root the resolved theme is emitted by toCss(theme, { selector: ':host', layer: 'rasd' }) (12 §4) rather than on .rasd-root, because declarations written by the outer document against the shadow host beat :host rules regardless of specificity. That is what makes token piercing work: the host writes rasd-form { --rasd-color-primary: #A21C1C; --rasd-typography-fontFamily: 'Noto Sans Arabic' } and the value inherits down the shadow tree. Variable names are the mechanical --rasd-<group>-<key> form with key names verbatim (12 §4); the theme attribute/property applies a full theme JSON instead. part="root" and part="button-primary" are exposed for ::part() (12 §5) — everything else is tokens. Storage is the Dexie adapter on the host origin (shared quota and Web Locks); pass a storage property to share one adapter between several elements.

11.4 iframe embed (postMessage API) — online-only, phase 3

For hosted "share links" only. Contract: iframe → parent { source: 'rasd', type: 'ready' | 'resize' | 'change' | 'finalize' | 'error', payload } with an explicit targetOrigin; parent → iframe { source: 'rasd', type: 'init' | 'setLocale' | 'setTheme' | 'finalize', payload }; both sides validate event.origin against an allowlist and ignore *. <iframe allow="camera; microphone; geolocation" sandbox="allow-scripts allow-forms allow-same-origin">. Storage inside the frame is partitioned and small; drafts survive a reload of the same page but not the ITP purge — the UI says so.

12. CSP requirements

DirectiveRequirementWhy
script-src'self' (npm/self-hosted IIFE); CDN host or nonce + 'strict-dynamic' for CDN. No 'unsafe-eval'REL is parsed and evaluated in a sandbox without eval/new Function (spine §5)
worker-src'self' (fallback chain child-srcscript-srcdefault-src)SW and Rasd media workers (image compression) are same-origin
connect-srchost RSP origin, tus attachment origin, host tokenEndpointDevices never call Rasd origins in production
img-src / media-src'self' blob: data: + media originsOffline photos/audio are blob: URLs from IndexedDB; data: only for inline signature previews
font-src'self'Self-hosted WOFF2
style-src'self' + nonce via RasdProvider cssNonce / <rasd-form css-nonce>; constructed stylesheets need no nonceTheme variables are injected as a <style> when adoptedStyleSheets is unavailable
frame-src / frame-ancestorsonly for the iframe embed
Trusted Typessupported: sanitized markdown returns a TrustedHTML via DOMPurify RETURN_TRUSTED_TYPE (16 · Security)

CI runs the playground under Content-Security-Policy: default-src 'self'; style-src 'self' 'nonce-…'; img-src 'self' blob:; worker-src 'self' and fails on any violation report.

13. SSR notes

  • Every @rasd/react and @rasd/pwa entry that touches state, DOM, IndexedDB or navigator.serviceWorker starts with 'use client' and keeps it in built output; @rasd/core stays directive-free so validateFormDefinition() runs in Server Components and route handlers.
  • Storage is passed as a factory (storage={() => createDexieStorage(...)}) and opened in an effect; useServiceWorkerUpdate/useInstallPrompt return inert state on the server. Load @rasd/builder with next/dynamic(() => import('@rasd/builder'), { ssr: false }).
  • <rasd-form> under React 19 SSR: attributes serialise, the element upgrades on the client; v1 does not ship Declarative Shadow DOM markup (open question). Server-render a skeleton of the same height.
  • Locale for the first paint comes from settings.defaultLocale (or a cookie the host reads); navigator.language is applied after hydration to avoid mismatches.

14. Size budgets (min+gzip)

ArtifactBudgetEnforced by
Form runner (core + react + storage-dexie + sync), excl. React≤ 120 kB (spine §12)size-limit
@rasd/pwa (window entry)≤ 6 kBsize-limit
@rasd/pwa/sw (excl. workbox-*)≤ 8 kBsize-limit
rasd-sw.js prebuilt (incl. Workbox modules used)≤ 40 kBsize-limit
rasd-forms.iife.js (React 19 + runner + element glue + license)≤ 175 kBsize-limit (18 §10 owns .size-limit.js); heavy elements (matrix, geo, signature) and locales load as separate chunks next to the IIFE
Any single chunk, uncompressed< 2 MiB (Workbox default)build check
Default Arabic font subset≤ 120 kB WOFF2asset check

14.1 The IIFE budget is not met — measured 2026-08-28

.size-limit.js in 18 §10 puts packages/element/dist/rasd-forms.iife.js at 175 kB. The first build of that bundle came in at 186–191 kB gzipped (609 kB raw; the range is gzip level, not build variance). The budget is stated here as unmet rather than quietly widened, because the number is a design constraint on a decision that has not been taken yet.

Where it goes, measured from the bottom up:

LayerCumulative, min+gzip
react + react-dom + scheduler~60 kB
+ @rasd/react (pulls @rasd/core, @rasd/media, @rasd/themes)~130 kB
+ @rasd/storage-dexie~175 kB — the entire budget
+ @rasd/license, @rasd/sync, the element, the CSS~186–191 kB

Dexie cannot simply be dropped to make room: createLicense() takes a required storage handle and reads license.token / license.trial.startedAt from kv, so there is no licence-without-storage path. Closing the gap therefore means one of four decisions, none of which is this release's to make:

  1. Raise the budget to ~200 kB and say so in 18 §10.
  2. Ship a second, smaller bundle — renderer + memory storage, no licence, no sync — for hosts that only need a form on a page.
  3. Give createLicense() a storage-free mode, which lets the element drop @rasd/storage-dexie when the host passes its own adapter.
  4. Externalise React behind an import map, which trades ~60 kB for an integration step the target audience specifically does not have.

packages/element/test/bundle.test.ts pins the current size at a 200 kB ceiling so it cannot drift further while that is decided, and logs the overage on every run.

15. Security of embedding

  • License binding. The RLT is public by design (it ships in bundles); the apps[] claim (≤ 50 entries) binds it to origins/bundle IDs. Matching rule for web, normative in 15 §4.2: compare location.origin (scheme + host + port) of the document against each pattern, host compared case-insensitively; https://*.example.org matches any depth of subdomain but not the apex — list the apex separately; a pattern without a port matches only the scheme's default port, and https://forms.example.org:* matches any port; http:// patterns are allowed but warned; empty apps[] = any origin. Inside <rasd-form> the origin is the document's; inside an iframe it is the frame's. A mismatch yields invalid with reason: 'APP_MISMATCH' (treated as no token → trial/limited per spine §9). Dev origins (localhost, 127.0.0.1, [::1], *.local) short-circuit to evaluating only when no usable token is present — a valid token on a dev origin is applied normally; WebView schemes (capacitor://localhost, ionic://) are not dev origins and must be listed literally.
  • Never accept postMessage configuration without origin validation; definition-url and media must match the allowlist (mediaAllowList defaults to the sync origin, 16 · Security).
  • SRI + crossorigin="anonymous" + exact versions on every CDN script; the IIFE leaks no globals besides Rasd and the element definitions and does not polyfill globally.
  • Third-party scripts on the form origin can read IndexedDB — say so, and recommend a dedicated origin (forms.example.org) for the field PWA.

16. Testing

  • Playwright projects chromium, chromium-ar-rtl, offline, webkit: context.setOffline(true) after navigator.serviceWorker.ready; assert caches.keys() contains rasd-*-v1 and no cache holds /v1/; reload offline, fill and finalize; go online and assert POST /v1/submissions:batch with Idempotency-Key. Playwright routes do not intercept SW-controlled fetches unless serviceWorkers: 'block' — use two configs (SW on for caching, SW blocked for API mocking).
  • Update safety: build twice with different hashes, register v1, dirty a form, deploy v2, registration.update(), assert updateAvailable && deferred and no framenavigated; finalize, then assert exactly one reload with v2 controlling.
  • Install/persist: dispatch a synthetic beforeinstallprompt; use CDP Storage.overrideQuotaForOrigin to force the low-storage and RASD_STORAGE_QUOTA paths.
  • Recipes matrix in CI (each passes the §10 checklist): examples/vite-generate-sw, vite-inject-manifest, next-serwist-webpack, next-serwist-turbopack, expo-web-workbox-cli, plain-html-iife.
  • Element tests (Vitest browser mode): date picker styled inside the shadow root, rasd:finalize bubbles and is composed, ::part(root) applies, host CSS does not leak in, host --rasd-* does.
  • CSP: playground served with the §12 policy and a report-to endpoint; any report fails the run.

17. Failure modes, accessibility and performance

17.1 Failure modes

FailureTriggerBehaviourHost action
No service worker at allplain HTTP, worker-src blocked, private mode, enterprise policy, register() rejectsForms, autosaved drafts and the outbox keep working from @rasd/storage-dexie; only cached media/fonts, the offline navigation fallback and installability are lost. useServiceWorkerUpdate() reports updateAvailable: false, offlineReady: falsesurface the SW row in the diagnostics screen (§7); consider the narrow-scope worker from §3.6
Host route shadows /v1/*the host registers a broad StaleWhileRevalidate API route before the Rasd routesWorkbox matches routes in registration order, so a stale delta could be served and the outbox would re-send. registerRasdRoutes() / rasdSerwistRoutes() must come first; the ordering test in §18 is the guardkeep Rasd entries at the head of runtimeCaching
Cache write refusedcaches.put() throws QuotaExceededErrorpurgeOnQuotaError evicts rasd-media-v1 first — media is re-fetchable, submissions are not; precacheForms() records the URL in failed[] and resolves, never throws, never blocks a capturelower precacheForms({ maxBytes }); sync so synced attachments purge
Opaque or undecodable media responsecross-origin CDN without CORS, truncated range responsestatus 0 is still cached by CacheableResponsePlugin({ statuses: [0, 200] }); a body that fails to decode renders the media placeholder with the label text — never a blank questionprefer CORS-enabled media origins so failures are visible
Reload while a draft is dirtyapplyUpdate({ force: true }), host router, OS tab killthe last autosave has already committed (settings.autosaveMs ≤ 2 s plus the pagehide flush, 06 §4) and the draft resumes by submissionId; RASD_SW_UPDATE_WHILE_BUSY is logged when force bypassed the guardnever call applyUpdate({ force: true }) from a form route
Older cached JS, newer databaserollback, or a second tab that already updatedRASD_STORAGE_DOWNGRADE, read-only open, export() still works (§5.2)prompt the update when busy is false
ITP purge on a non-installed iOS tab7 days of Safari use without interactionunrecoverable — drafts and outbox go with the rest of script-writable storage. Prevention only: <InstallHint>, the 7-day warning while pending submissions exist, sync on every foregroundmake install part of enumerator onboarding
Background Sync fires with no open clientChromium wakes the worker, every window is closedRasdOutboxPlugin rejects so the tag stays pending, and resolves on event.lastChance; the outbox is untouched and the next foreground syncNow() drains itnone
beforeinstallprompt never firesiOS, already installed, criteria unmetcanPrompt: false; <InstallHint> shows the platform's manual path instead of a dead buttonnever render an install button off canPrompt === false
IIFE loaded twicetwo <script> tags, or two pinned versions on one pagedefineRasdElement() no-ops when customElements.get('rasd-form') already resolves and logs both Rasd.version values; the first definition winspin exactly one version per page
<rasd-form> never upgradesscript blocked by CSP or an SRI mismatchthe tag stays an unknown element; its light-DOM children are the fallback content and the host CSS min-height prevents layout shiftput a link to a hosted form inside the element as fallback content
postMessage from an unexpected originhostile parent or framemessages whose event.origin is not on the allowlist are dropped (never *) and reported through rasd:errorset an explicit targetOrigin on both sides

17.2 Accessibility

  • Update, sync and storage banners are host UI, but the strings and behaviour Rasd ships must hold the WCAG 2.2 AA floor of 13 · i18n, RTL & accessibility. They are status messages (SC 4.1.3): aria-live="polite" for "update available" and "saved offline", role="alert" only for the ones that block work (storage critical, finalize failed).
  • An update prompt must never steal focus mid-question: render it as a non-modal banner and keep the reload behind an explicit control. This is the accessibility reason the library refuses to reload on its own, on top of the data-safety reason in §5.1.
  • <InstallHint> ships localized en/ar/fr copy through the chrome catalogs (13 §7), mirrors the share icon under dir="rtl" (§6), is dismissible from the keyboard, never auto-focuses and never traps focus; banner transitions honour prefers-reduced-motion.
  • The shadow boundary in <rasd-form> changes three things that matter: document.activeElement retargets to the host (use shadowRoot.activeElement for focus management), aria-describedby / aria-labelledby cannot cross the boundary — so hints, error text and the error summary must live in the same root as the control they describe — and portalled popovers must render into portalContainer inside the shadow root or they lose their styles and their ARIA relationships.

17.3 Performance

  • Precache the shell only: the runner is ≤ 120 kB gz (§14) and heavy element chunks load on demand, so a cold install over a 3G link stays small; @rasd/builder is never in a field runner's precache.
  • precacheForms() defaults to concurrency: 4 — enough to saturate a slow link without starving the main thread on a Moto G-class device; it is AbortSignal-cancellable and reports bytes so a "Download for offline" screen shows real progress against maxBytes.
  • rasd-media-v1 and rasd-fonts-v1 are CacheFirst precisely to avoid a revalidation round-trip per asset on a high-latency link; rasd-defs-v1 is StaleWhileRevalidate so a form opens from cache and refreshes behind the user.
  • Enable navigationPreload in the host worker (as in §3.4) so service-worker boot does not sit in front of the navigation request. Nothing in the render path awaits the worker — every read and write goes through local storage first (spine P1) — so a cold SW start never delays a question.
  • The IIFE is the heaviest entry (§14) and parses on the main thread: self-host it, precache it, load it defered, and prefer the npm path whenever the host already ships React.
  • One constructed stylesheet is shared by every <rasd-form> on the page (adoptedStyleSheets), so N elements cost one style computation rather than N injected <style> blocks.

18. Acceptance criteria

  • registerRasdRoutes() and rasdRuntimeCaching() produce identical cache behaviour under injectManifest, generateSW and Serwist (golden Playwright run per recipe).
  • /v1/* requests are NetworkOnly even when the host adds a broad StaleWhileRevalidate runtime route (ordering test).
  • RasdOutboxPlugin never stores a request body; the sync event with an open client triggers exactly one syncNow().
  • useServiceWorkerUpdate().applyUpdate() returns 'deferred' while useRasdBusy() is true and never reloads without a host call.
  • Older-code/newer-DB opens read-only with RASD_STORAGE_DOWNGRADE; export works.
  • useInstallPrompt() reports platform: 'ios-safari' and <InstallHint> renders RTL Arabic steps; Chromium prompt() resolves accepted/dismissed.
  • requestPersistence() is called after first sync; useStoragePersistence().low flips and the banner appears at the on('quota') warning edge, and capture is blocked at the critical edge, with the quota forced through CDP.
  • <rasd-form> fills and finalizes a form offline from a plain HTML page using the self-hosted IIFE with SRI; popovers render inside the shadow root.
  • Bundle budgets in §14 pass size-limit; CSP run produces zero violations.
  • rasd pwa init copies rasd-sw.js and the narrow-scope registration works on a host without a SW.
  • Update, sync and storage banners announce as aria-live status messages and never move focus; inside <rasd-form> every error message resolves through aria-describedby within the shadow root (axe + keyboard-only run, 13).
  • A second IIFE <script> on the same page does not throw: the element definition no-ops, both Rasd.version values are logged, and the already-mounted form keeps working.

Open questions

  • Should <InstallHint>, <SyncStatus> and <StorageBanner> live in @rasd/pwa (as specified here) or in a @rasd/react/ui sub-path shared with native equivalents?
  • Advanced mode where the host bundles the sync engine + a token provider into the SW so RasdOutboxPlugin can drain the outbox with no open client — worth it given Chromium-only support?
  • Declarative Shadow DOM pre-rendering for <rasd-form> (removes upgrade flicker; needs React 19 SSR support of <template shadowrootmode>).
  • Periodic Background Sync for form-definition pull and a Web Push module (standard + declarative payloads): v1.x opt-in or phase 3?
  • apps[] wildcard semantics (apex inclusion, port wildcards, WebView schemes) are frozen in 15 §4.2 and mirrored in §15 here. Still open for this document: should a <rasd-form> running in a partitioned cross-origin iframe match on the frame's own origin (current rule) or be refused outright, given that its storage is not the host's?
  • Hosted iframe product: served by the RSP reference server or a separate Rasd Cloud surface?