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/pwacontributes 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/*) areNetworkOnlybecause the outbox in IndexedDB — not the SW — is the source of truth for sync. - Background Sync is a Chromium-only accelerator;
RasdOutboxPluginonly wakes the sync engine, it never replays requests itself. - Update UX is
waiting → prompt → SKIP_WAITING → controlling → reload, anduseServiceWorkerUpdate()refuses to reload whileuseRasdBusy()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'sapps[]claim binds a public token to allowed origins.
1. Responsibility split
| Concern | @rasd/pwa provides | Host app owns |
|---|---|---|
| Service-worker file & registration | Route/plugin factories, a prebuilt rasd-sw.js for importScripts, CLI copy of a narrow-scope fallback worker | sw.ts/sw.js, navigator.serviceWorker.register(), scope, precache manifest, cleanupOutdatedCaches |
| App-shell precache | Nothing (adds no entries to the host manifest) | Bundler plugin (vite-plugin-pwa, Serwist, workbox-cli) |
| Runtime caching of form assets | registerRasdRoutes(), rasdRuntimeCaching(), precacheForms() | Which origins are allowed, cache versions of their assets |
| Sync | Background-Sync wake bridge (RasdOutboxPlugin + connectBackgroundSync()) | Nothing — the outbox lives in @rasd/sync |
| Update UX | useServiceWorkerUpdate() (state + guarded applyUpdate()) | Prompt UI, deciding when to call applyUpdate() |
| Install | useInstallPrompt(), <InstallHint> strings (en/ar/fr, RTL-aware) | Placement, manifest, icons |
| Storage durability | useStoragePersistence(), requestPersistence() | Diagnostics screen, retention policy |
| Manifest / offline page | Documented templates | The 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
| Cache | What | Strategy | Plugins / limits | Notes |
|---|---|---|---|---|
| host precache | App shell built by the host | Precache | host's cleanupOutdatedCaches() | Rasd chunks must each stay < 2 MiB uncompressed; @rasd/builder is a lazy chunk the field runner never precaches |
rasd-defs-v1 | URL-addressed RFD JSON (definition-url, datasets[].source:"url", *.form.json) | StaleWhileRevalidate | ExpirationPlugin({ maxEntries: 200 }), CacheableResponsePlugin({ statuses: [0, 200] }) | RSP-pulled definitions are not here — the sync engine stores them in storage.forms |
rasd-media-v1 | element.media.image/audio, choice images, attachment previews from the server | CacheFirst | ExpirationPlugin({ 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-v1 | Theme WOFF2 (incl. default OFL Arabic family) | CacheFirst | ExpirationPlugin({ 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) | NetworkOnly | RasdOutboxPlugin 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.outboxis the only queue.RasdOutboxPluginnever stores requests. connectBackgroundSync({ sync, registration })callsregistration.sync.register('rasd-outbox')whenever a submission entersqueuedwhile offline or a batch fails with a network error, and listens for{ type: 'rasd:sync-wake' }messages →sync.syncNow().- In the worker,
RasdOutboxPluginhandles thesyncevent: postrasd:sync-waketo every window client; if there is no client, reject (keeps the tag pending so Chromium retries with its own backoff) unlessevent.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:
| Situation | Behaviour |
|---|---|
| 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 DB | Dexie 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 ahead | RASD_SCHEMA_INVALID with details.reason: 'majorAhead'; the form is listed but not openable |
| Two tabs, different versions | Web 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()onbeforeinstallprompt, keep the event, exposeprompt(), readuserChoice, listen toappinstalled. Chrome no longer requires a SW fetch handler to install (108 Android / 112 desktop); the richer install sheet needsdescription+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 underdir="rtl". - Show the hint after the first successful sync or after 2 sessions, not on first paint; suppress when
installed; persist dismissal instorage.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).
| Rule | Value |
|---|---|
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 banner | the 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) |
| Critical | the 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 errors | any 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 mode | detect via reduced quota / OPFS absence; warn that data may be wiped when the window closes |
| Diagnostics | host "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
| Capability | Safari / iOS status | Rasd behaviour |
|---|---|---|
| Background Sync / Periodic Sync | Not supported (through Safari 26.5) | Foreground triggers only; connectBackgroundSync no-ops |
| Web Push | Home-Screen web apps only (iOS 16.4+); Declarative Web Push from iOS 18.4 needs no SW | Optional module; send declarative payloads; gate on installed |
| Storage quota | ~60 % per origin (browser), 15 % in WKWebView, 10 % for cross-origin frames | Fine for surveys; iframe embeds are online-only |
| 7-day ITP purge | Applies to Safari tabs; Home-Screen apps exempt | Install hint + warning + aggressive sync |
navigator.storage.persist() | 15.2+, heuristic (installed apps favoured) | Retry on each start; show status |
| Camera / geolocation in standalone | getUserMedia, <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 |
beforeinstallprompt | Absent | iOS 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 origin | Prefer @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-v1present;/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"]
| Model | CSS isolation | Same-origin storage & SW | React duplication | CSP friction | Offline field use |
|---|---|---|---|---|---|
| npm React package | none (opt-in Shadow via @rasd/element wrapper) | yes | none | lowest | primary |
| Script tag / IIFE | Shadow DOM inside bundle | yes | bundles React (~45 kB gz) | needs script-src host or nonce; SRI | yes when self-hosted |
<rasd-form> Web Component | strong | yes | none if the host externalises React, else bundled | style injection must be nonce'd | yes |
| iframe | total | partitioned (Chrome 115+), 10 % Safari quota, no host SW | none | frame-src / frame-ancestors | no — 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 exposeswindow.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/…, neverlatest) so precache revisions and SRI hashes are stable;SRI.jsonwith 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.devexists for prototyping and CodePen-style demos. - Config precedence: element attributes →
data-*ondocument.currentScript→Rasd.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
| Directive | Requirement | Why |
|---|---|---|
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-src → script-src → default-src) | SW and Rasd media workers (image compression) are same-origin |
connect-src | host RSP origin, tus attachment origin, host tokenEndpoint | Devices never call Rasd origins in production |
img-src / media-src | 'self' blob: data: + media origins | Offline 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 nonce | Theme variables are injected as a <style> when adoptedStyleSheets is unavailable |
frame-src / frame-ancestors | only for the iframe embed | — |
Trusted Types | supported: 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/reactand@rasd/pwaentry that touches state, DOM, IndexedDB ornavigator.serviceWorkerstarts with'use client'and keeps it in built output;@rasd/corestays directive-free sovalidateFormDefinition()runs in Server Components and route handlers. - Storage is passed as a factory (
storage={() => createDexieStorage(...)}) and opened in an effect;useServiceWorkerUpdate/useInstallPromptreturn inert state on the server. Load@rasd/builderwithnext/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.languageis applied after hydration to avoid mismatches.
14. Size budgets (min+gzip)
| Artifact | Budget | Enforced by |
|---|---|---|
Form runner (core + react + storage-dexie + sync), excl. React | ≤ 120 kB (spine §12) | size-limit |
@rasd/pwa (window entry) | ≤ 6 kB | size-limit |
@rasd/pwa/sw (excl. workbox-*) | ≤ 8 kB | size-limit |
rasd-sw.js prebuilt (incl. Workbox modules used) | ≤ 40 kB | size-limit |
rasd-forms.iife.js (React 19 + runner + element glue + license) | ≤ 175 kB | size-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 WOFF2 | asset 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:
| Layer | Cumulative, 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:
- Raise the budget to ~200 kB and say so in 18 §10.
- Ship a second, smaller bundle — renderer + memory storage, no licence, no sync — for hosts that only need a form on a page.
- Give
createLicense()a storage-free mode, which lets the element drop@rasd/storage-dexiewhen the host passes its own adapter. - 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: comparelocation.origin(scheme + host + port) of the document against each pattern, host compared case-insensitively;https://*.example.orgmatches any depth of subdomain but not the apex — list the apex separately; a pattern without a port matches only the scheme's default port, andhttps://forms.example.org:*matches any port;http://patterns are allowed but warned; emptyapps[]= any origin. Inside<rasd-form>the origin is the document's; inside an iframe it is the frame's. A mismatch yieldsinvalidwithreason: 'APP_MISMATCH'(treated as no token → trial/limited per spine §9). Dev origins (localhost,127.0.0.1,[::1],*.local) short-circuit toevaluatingonly 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
postMessageconfiguration without origin validation;definition-urland media must match the allowlist (mediaAllowListdefaults to the sync origin, 16 · Security). - SRI +
crossorigin="anonymous"+ exact versions on every CDN script; the IIFE leaks no globals besidesRasdand 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)afternavigator.serviceWorker.ready; assertcaches.keys()containsrasd-*-v1and no cache holds/v1/; reload offline, fill and finalize; go online and assertPOST /v1/submissions:batchwithIdempotency-Key. Playwright routes do not intercept SW-controlled fetches unlessserviceWorkers: '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(), assertupdateAvailable && deferredand noframenavigated; finalize, then assert exactly one reload with v2 controlling. - Install/persist: dispatch a synthetic
beforeinstallprompt; use CDPStorage.overrideQuotaForOriginto force the low-storage andRASD_STORAGE_QUOTApaths. - 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:finalizebubbles 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-toendpoint; any report fails the run.
17. Failure modes, accessibility and performance
17.1 Failure modes
| Failure | Trigger | Behaviour | Host action |
|---|---|---|---|
| No service worker at all | plain HTTP, worker-src blocked, private mode, enterprise policy, register() rejects | Forms, 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: false | surface 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 routes | Workbox 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 guard | keep Rasd entries at the head of runtimeCaching |
| Cache write refused | caches.put() throws QuotaExceededError | purgeOnQuotaError 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 capture | lower precacheForms({ maxBytes }); sync so synced attachments purge |
| Opaque or undecodable media response | cross-origin CDN without CORS, truncated range response | status 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 question | prefer CORS-enabled media origins so failures are visible |
| Reload while a draft is dirty | applyUpdate({ force: true }), host router, OS tab kill | the 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 guard | never call applyUpdate({ force: true }) from a form route |
| Older cached JS, newer database | rollback, or a second tab that already updated | RASD_STORAGE_DOWNGRADE, read-only open, export() still works (§5.2) | prompt the update when busy is false |
| ITP purge on a non-installed iOS tab | 7 days of Safari use without interaction | unrecoverable — 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 foreground | make install part of enumerator onboarding |
| Background Sync fires with no open client | Chromium wakes the worker, every window is closed | RasdOutboxPlugin rejects so the tag stays pending, and resolves on event.lastChance; the outbox is untouched and the next foreground syncNow() drains it | none |
beforeinstallprompt never fires | iOS, already installed, criteria unmet | canPrompt: false; <InstallHint> shows the platform's manual path instead of a dead button | never render an install button off canPrompt === false |
| IIFE loaded twice | two <script> tags, or two pinned versions on one page | defineRasdElement() no-ops when customElements.get('rasd-form') already resolves and logs both Rasd.version values; the first definition wins | pin exactly one version per page |
<rasd-form> never upgrades | script blocked by CSP or an SRI mismatch | the tag stays an unknown element; its light-DOM children are the fallback content and the host CSS min-height prevents layout shift | put a link to a hosted form inside the element as fallback content |
postMessage from an unexpected origin | hostile parent or frame | messages whose event.origin is not on the allowlist are dropped (never *) and reported through rasd:error | set 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 underdir="rtl"(§6), is dismissible from the keyboard, never auto-focuses and never traps focus; banner transitions honourprefers-reduced-motion.- The shadow boundary in
<rasd-form>changes three things that matter:document.activeElementretargets to the host (useshadowRoot.activeElementfor focus management),aria-describedby/aria-labelledbycannot 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 intoportalContainerinside 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/builderis never in a field runner's precache. precacheForms()defaults toconcurrency: 4— enough to saturate a slow link without starving the main thread on a Moto G-class device; it isAbortSignal-cancellable and reports bytes so a "Download for offline" screen shows real progress againstmaxBytes.rasd-media-v1andrasd-fonts-v1areCacheFirstprecisely to avoid a revalidation round-trip per asset on a high-latency link;rasd-defs-v1isStaleWhileRevalidateso a form opens from cache and refreshes behind the user.- Enable
navigationPreloadin 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()andrasdRuntimeCaching()produce identical cache behaviour underinjectManifest,generateSWand Serwist (golden Playwright run per recipe). -
/v1/*requests areNetworkOnlyeven when the host adds a broadStaleWhileRevalidateruntime route (ordering test). -
RasdOutboxPluginnever stores a request body; thesyncevent with an open client triggers exactly onesyncNow(). -
useServiceWorkerUpdate().applyUpdate()returns'deferred'whileuseRasdBusy()is true and never reloads without a host call. - Older-code/newer-DB opens read-only with
RASD_STORAGE_DOWNGRADE; export works. -
useInstallPrompt()reportsplatform: 'ios-safari'and<InstallHint>renders RTL Arabic steps; Chromiumprompt()resolvesaccepted/dismissed. -
requestPersistence()is called after first sync;useStoragePersistence().lowflips and the banner appears at theon('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 initcopiesrasd-sw.jsand the narrow-scope registration works on a host without a SW. - Update, sync and storage banners announce as
aria-livestatus messages and never move focus; inside<rasd-form>every error message resolves througharia-describedbywithin the shadow root (axe + keyboard-only run, 13). - A second IIFE
<script>on the same page does not throw: the element definition no-ops, bothRasd.versionvalues 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/uisub-path shared with native equivalents? - Advanced mode where the host bundles the sync engine + a token provider into the SW so
RasdOutboxPlugincan 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?
Related documents
- 00 · Decisions & conventions
- 02 · Requirements
- 03 · Architecture
- 06 · Renderer (React)
- 09 · Offline storage
- 10 · Sync protocol
- 12 · Theming
- 13 · i18n, RTL & accessibility
- 14 · Media & field capture
- 15 · Licensing & billing
- 16 · Security & data protection
- 17 · API reference
- 18 · Engineering practices
- 21 · Getting started