21 · Getting started & cookbook
Purpose: Copy-paste quickstarts that take a developer from an empty project to an offline-first, synced, licensed, themed Rasd Forms integration on Vite, Next.js, Expo and a plain HTML page — plus the twelve most common customisations, a before-you-ship checklist (security, accessibility, performance), a troubleshooting table, an FAQ and an upgrade-guide skeleton.
Audience: Developers at UN/NGO organisations integrating Rasd Forms into their own web app, PWA or React Native app; the Rasd team maintaining apps/playground-web, apps/example-next and apps/example-expo, which are the executable versions of these recipes.
TL;DR
- Minimum viable web integration is four packages (
@rasd/core,@rasd/react,@rasd/storage,@rasd/storage-dexie) and eight lines of JSX; sync, PWA, license, media and builder are adapters you add later without touching the form code. - Every recipe follows the same order: storage → provider → renderer → sync → PWA → license → theme — offline is the default (spine P1) and the renderer never awaits the network.
- Toolchain baseline: React 19, Vite 7/8 (
vite-plugin-pwa1.3.x), Next.js 15/16 with Serwist 9 (nevernext-pwa), Expo SDK ≥ 54 / RN ≥ 0.81 New Architecture, Node 22 LTS (≥ 22.13) for tooling (research/08, research/05). - On
localhost,127.0.0.1,*.localand Expo dev the license state isevaluating; in production refresh through atokenEndpointproxy on your backend — end-user devices never call Rasd. - Example forms:
docs/examples/pdm-food-distribution.form.jsonanddocs/examples/facility-monitoring-visit.form.json(both validate againstschema/rasd-form.schema.json). - The library never registers a service worker, never reloads the page and never manages end-user identity — the recipes show exactly where you plug those in.
- Unit-test with
@rasd/testing(renderForm,fakeStorage,fakeClock,fakeMedia,faultyNetwork) — no real IndexedDB, camera or network needed.
1. Before you start
| Need | Packages | Notes |
|---|---|---|
| Render a form (web) | @rasd/core @rasd/react @rasd/storage @rasd/storage-dexie (+ peer dexie ^4.4) | @rasd/themes optional; built-in ids rasd-light, rasd-dark, rasd-high-contrast, rasd-field |
| Render a form (RN/Expo) | @rasd/core @rasd/native @rasd/storage @rasd/storage-sqlite @rasd/media (+ expo-sqlite, expo-secure-store, expo-file-system) | Development build; Expo Go = plaintext DB, prototyping only (07 §20) |
| Sync | @rasd/sync | Needs an RSP server: @rasd/server reference or your own (10) |
| Offline PWA | @rasd/pwa (+ vite-plugin-pwa 1.3.x or serwist 9.x) | You own sw.ts and the manifest |
| Production license | @rasd/license (+ @rasd/server/license on your backend) | Trial needs nothing |
| Builder | @rasd/builder | Lazy-load it; separate chunk |
| Framework-agnostic embed | @rasd/element or the self-hosted rasd-forms.iife.js | Shadow DOM <rasd-form> |
| Tooling | @rasd/cli (rasd validate, rasd convert xlsform, rasd types, rasd theme check, rasd license check, rasd doctor) | Apache-2.0 |
All packages are ESM-only with exports maps (react-native condition first); use pnpm 11 (or npm ≥ 11) and a bundler that honours exports — Vite, Next 15+ and Metro ≥ 0.82 do. Test with Vitest 4 on web and Jest + @react-native/jest-preset on native; do not require() them from CommonJS configs (research/08 §5, §10).
flowchart LR
A["Which host?"] -->|"React SPA"| V["Section 2: Vite"]
A -->|"Next.js"| N["Section 5: Next.js"]
A -->|"React Native"| E["Section 6: Expo"]
A -->|"Non-React site"| H["Section 11: script tag"]
V --> S["Section 3: sync"] --> P["Section 4: PWA"] --> L["Section 7: license"] --> T["Section 8: theme"]
N --> S
E --> S
2. Quickstart 1 — Vite + React: render, autosave, list drafts
pnpm create vite@latest pdm-demo --template react-ts && cd pdm-demo
pnpm add react@^19 react-dom@^19 dexie@^4.4 @rasd/core @rasd/react @rasd/storage @rasd/storage-dexie @rasd/themes
mkdir -p src/forms && cp ../rasd-forms/docs/examples/pdm-food-distribution.form.json src/forms/
src/rasd.ts — one storage adapter per app, created once at module level (one IndexedDB database per namespace); storing the definition lets drafts pin formVersion + definitionHash:
import { createDexieStorage } from '@rasd/storage-dexie';
import type { FormDefinition } from '@rasd/core';
import pdmJson from './forms/pdm-food-distribution.form.json';
export const pdm = pdmJson as FormDefinition;
export const storage = createDexieStorage({ namespace: 'pdm-demo' });
export const ready = storage.open({ namespace: 'pdm-demo' }).then(() => storage.forms.put(pdm)); // both idempotent
src/main.tsx awaits ready before createRoot(...).render(<App />). src/App.tsx:
import { useState } from 'react';
import { RasdProvider, FormRenderer } from '@rasd/react';
import '@rasd/react/styles.css';
import { storage, pdm } from './rasd';
import { DraftList } from './DraftList';
export function App() {
const [draftId, setDraftId] = useState<string | undefined>();
return (
<RasdProvider storage={storage} theme="rasd-field" locale="en">
<DraftList formId={pdm.id} onOpen={setDraftId} />
<FormRenderer
key={draftId ?? 'new'} // remount to switch between drafts
definition={pdm}
submissionId={draftId} // undefined ⇒ new UUID v7 draft
onSave={(s) => console.debug('autosaved', s.id, s.clientRev)}
onFinalize={(s) => { console.log('finalized', s.id); setDraftId(undefined); }}
/>
</RasdProvider>
);
}
src/DraftList.tsx — drafts are just submissions rows with status: 'draft'; storage.on('change') fires after every autosave commit in every tab:
import { useEffect, useState } from 'react';
import type { SubmissionSummary } from '@rasd/storage';
import { storage } from './rasd';
export function DraftList({ formId, onOpen }: { formId: string; onOpen: (id: string) => void }) {
const [drafts, setDrafts] = useState<SubmissionSummary[]>([]);
useEffect(() => {
const load = () => storage.submissions
.list({ formId, status: 'draft', order: 'updatedAt', direction: 'desc', limit: 50 })
.then((r) => setDrafts(r.items as SubmissionSummary[]));
void load();
return storage.on('change', load);
}, [formId]);
return (
<ul aria-label="Drafts">
{drafts.map((d) => (
<li key={d.id}><button onClick={() => onOpen(d.id)}>{d.instanceName ?? d.id} — {new Date(d.updatedAt).toLocaleString()}</button></li>
))}
</ul>
);
}
Without further configuration you get autosave every settings.autosaveMs (2 000 ms in the example) flushed on page change, visibilitychange, pagehide and unmount; skip logic, calculations and constraints from the RFD; RTL for locale="ar"; open() requests navigator.storage.persist(). No encryptionKey was passed, so the console prints one red warning — see 09 §8 for deriveKeyFromPassphrase() before you ship PII.
-
pnpm dev, fill two pages, reload: the draft is listed and reopens on the same page. - DevTools → Application → IndexedDB →
rasd__pdm-demoshowsforms,submissions,attachments,datasets,outbox,kv.
3. Quickstart 2 — add sync
pnpm add @rasd/sync
// src/rasd.ts (continued)
import { createSyncEngine } from '@rasd/sync';
export const sync = createSyncEngine({
storage,
baseUrl: import.meta.env.VITE_RSP_URL, // e.g. https://forms.example.org/api (RSP paths are under /v1)
getAuthToken: async ({ forceRefresh }) => auth.getAccessToken({ forceRefresh }), // your IdP; Rasd never manages users
policy: { requireHttps: !import.meta.env.DEV }, // http://localhost only in dev
});
Pass it to the provider (<RasdProvider storage={storage} sync={sync}>) and show status — the UI must always be able to display last sync time and pending counts (spine §8):
import { useSync } from '@rasd/react';
export function SyncBar() {
const { state, pending, lastSyncAt, lastError, syncNow } = useSync();
return (
<div role="status">
{state} · {pending.submissions} submissions / {pending.attachments} attachments pending · last {lastSyncAt ?? 'never'}
{lastError && <span> · {lastError.code}</span>}
<button onClick={() => syncNow()}>Sync now</button>
</div>
);
}
Nothing else changes: FormRenderer already writes finalized submissions to storage.outbox; the engine drains it on online, app foreground, finalize and a 15-minute timer, in the order submissions → attachments → forms pull → datasets pull (→ records when policy.records.enabled), with exponential backoff and full jitter (1 s → 5 min cap) on failures. Create the engine once at module level (single Web-Locks leader across tabs). For a local server run the @rasd/server reference (Hono + Postgres + tus, 10 §9) and point VITE_RSP_URL at it; @rasd/testing's rspConformance() verifies a server you write yourself.
- Finalize offline (DevTools → Network → Offline): the submission row is
queuedandpending.submissions: 1; go online:useSync().statepasses throughsyncing(the engine'sSyncStatus.stateisrunning, 10 §4.9) and the row movesqueued → sending → synced. - A 401 triggers exactly one
getAuthToken({ forceRefresh: true }); a second 401 parks the engine inauthRequired(RASD_SYNC_AUTH) untilresume(). - A server 4xx on one item marks only that submission
rejected({ code, message, field }surfaced in the UI); the user fixes it and re-finalizes — the rest of the batch still becomessynced.
4. Quickstart 3 — offline PWA
Rasd never owns your service worker; it contributes routes (11 §2). With vite-plugin-pwa 1.3.x (generateSW):
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { rasdRuntimeCaching } from '@rasd/pwa/workbox-config';
export default defineConfig({
plugins: [react(), VitePWA({
registerType: 'prompt', // never 'autoUpdate' for field apps
workbox: {
runtimeCaching: rasdRuntimeCaching({ apiOrigin: 'https://forms.example.org' }),
cleanupOutdatedCaches: true, navigateFallbackDenylist: [/^\/v1\//], maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
},
manifest: { name: 'PDM Monitor', short_name: 'PDM', start_url: '/', display: 'standalone', lang: 'ar', dir: 'rtl',
icons: [{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' }, { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }] },
})],
});
If you author sw.ts yourself (injectManifest), call registerRasdRoutes({ apiOrigin }) from @rasd/pwa/sw after precacheAndRoute(self.__WB_MANIFEST). Then wire the update prompt so a mid-form reload can never lose data:
import { useRegisterSW } from 'virtual:pwa-register/react';
import { useServiceWorkerUpdate, useInstallPrompt, precacheForms } from '@rasd/pwa';
const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW();
const sw = useServiceWorkerUpdate({ vitePwa: { needRefresh, updateServiceWorker } }); // applyUpdate() returns 'deferred' while useRasdBusy()
const install = useInstallPrompt();
{sw.updateAvailable && <button disabled={sw.busy} onClick={() => sw.applyUpdate()}>Update app</button>}
{install.canPrompt && <button onClick={install.prompt}>Install</button>}
// after login or "Download for offline":
await precacheForms({ definitions: await storage.forms.listLatest(), maxBytes: 200 * 1024 * 1024 });
Facts behind this recipe (research/05): Background Sync is Chromium-only, so the IndexedDB outbox is the queue and sync events are only an accelerator; Safari purges storage of non-installed sites after 7 days of Safari use without interaction, so tell iOS users to Add to Home Screen; Chrome no longer requires a SW to install and Lighthouse's PWA category is gone — verify with the checklist instead.
-
pnpm build && pnpm preview, load once, go offline, reload: shell, form, fonts and choice images render; a draft can be filled and finalized. - Deploy a new build while a draft is dirty: banner shows,
applyUpdate()returns'deferred', reload happens only after finalize.
5. Quickstart 4 — Next.js App Router
@rasd/core is directive-free (runs in Server Components and route handlers); @rasd/react and @rasd/pwa entries carry 'use client'. Pass storage as a factory so nothing touches IndexedDB during SSR:
// app/providers.tsx
'use client';
import { RasdProvider } from '@rasd/react';
import { createDexieStorage } from '@rasd/storage-dexie';
export function Providers({ children }: { children: React.ReactNode }) {
return <RasdProvider storage={() => createDexieStorage({ namespace: 'moda' })} theme="rasd-field" locale="ar">{children}</RasdProvider>;
}
// app/layout.tsx (server component)
import '@rasd/react/styles.css';
import { Providers } from './providers';
export default function Layout({ children }: { children: React.ReactNode }) {
return <html lang="ar" dir="rtl"><body><Providers>{children}</Providers></body></html>;
}
// app/forms/[id]/page.tsx (server component validates, client component renders)
import { validateFormDefinition, type FormDefinition } from '@rasd/core';
import { FormClient } from './form-client';
export default async function Page({ params }: { params: Promise<{ id: string }> }) { // params is a Promise in Next 15+
const def: FormDefinition = await loadDefinition((await params).id); // your data source (DB, RSP GET /v1/forms/{id}/versions/{version}, file)
const v = validateFormDefinition(def); if (!v.ok) throw new Error(v.errors[0].message); // zod runs on the server only; production render paths never import it
return <FormClient definition={def} />;
}
form-client.tsx is 'use client' and renders <FormRenderer definition={definition} locale="ar" /> — pass locale explicitly so the first paint's dir matches the server. Load the builder with next/dynamic(() => import('@rasd/builder'), { ssr: false }). For offline, use @serwist/next (webpack) or @serwist/turbopack (Next 15/16 default) with rasdSerwistRoutes() placed before defaultCache so /v1/* stays NetworkOnly — full worker in 11 §3.4. next-pwa is archived; do not use it.
-
next buildproduces no hydration warnings on the form page; IndexedDB is first opened in an effect (check the Performance panel).
6. Quickstart 5 — Expo app with SQLite, camera and GPS
npx create-expo-app@latest pdm-mobile && cd pdm-mobile
npx expo install expo-sqlite expo-file-system expo-secure-store expo-crypto expo-camera expo-location expo-image-picker \
@react-native-community/netinfo react-native-gesture-handler react-native-reanimated
pnpm add @rasd/core @rasd/native @rasd/storage @rasd/storage-sqlite @rasd/sync @rasd/media @rasd/license @rasd/themes
app.json — the config plugin writes backup-exclusion rules, usage strings and background identifiers (07 §16):
{ "expo": { "plugins": [
["@rasd/native", { "backupExclusion": true, "permissions": { "camera": true, "location": "whenInUse" },
"usageDescriptions": { "camera": "Photos of the distribution site for the monitoring report." } }],
["expo-sqlite", { "useSQLCipher": true }]
] } }
src/rasd.ts — key in SecureStore under the namespaced id rasd.dbkey.<namespace> (00 §7, 09 §8.2), whole-database SQLCipher, never MMKV/AsyncStorage:
import { createSqliteStorage } from '@rasd/storage-sqlite';
import { createSyncEngine } from '@rasd/sync';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
const KEY_OPTS = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
const toHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); // 64 hex chars, well under SecureStore's ~2 KB cap
const NS = 'pdm';
const KEY_ID = `rasd.dbkey.${NS}`; // namespaced key id: rasd.dbkey.<namespace> (spine §7)
export async function boot() {
let key = await SecureStore.getItemAsync(KEY_ID, KEY_OPTS);
if (!key) { key = toHex(await Crypto.getRandomBytesAsync(32)); await SecureStore.setItemAsync(KEY_ID, key, KEY_OPTS); }
const storage = createSqliteStorage({ driver: 'expo', namespace: NS, encryptionKey: key });
await storage.open({ namespace: NS }); // WAL, PRAGMA key, migrations
const sync = createSyncEngine({ storage, baseUrl: process.env.EXPO_PUBLIC_RSP_URL!, getAuthToken: () => auth.token() });
return { storage, sync };
}
app/_layout.tsx and a form screen:
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { RasdProvider, FormRenderer } from '@rasd/native';
import { native as media } from '@rasd/media'; // expo-camera / expo-location / expo-image-picker, lazily loaded
import { rasdField } from '@rasd/themes';
export default function Layout() {
const rasd = useRasdBoot(); // awaits boot() once; <Splash/> meanwhile (same helper as 07 §14)
if (!rasd) return <Splash />;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RasdProvider storage={rasd.storage} sync={rasd.sync} media={media} theme={rasdField} locale="ar"><Stack /></RasdProvider>
</GestureHandlerRootView>
);
}
// app/form/[id].tsx
<FormRenderer definition={pdm} submissionId={draftId} onFinalize={() => router.replace('/')} />
The example form's image and geopoint questions now capture through the media adapters: permission requested on first use with a one-sentence rationale, denied-and-blocked falls back to allowManual/gallery, photos resized to 1 280 px long edge and EXIF-stripped, GPS auto-accepted at accuracyThreshold (14). Build with npx expo prebuild && npx expo run:android; Expo Go cannot load SQLCipher or the config plugin.
- Airplane mode: fill, photograph, capture GPS, finalize, force-quit, relaunch — the draft list shows the finalized item as
queued; connectivity ⇒synced. -
PRAGMA cipher_versionis non-empty in the dev build;storage.securityReport().encryption === 'sqlcipher'.
7. Quickstart 6 — the license token (trial → production proxy)
Nothing is needed on dev origins (evaluating). To test real states locally, get a 7-day trial token with rasd license trial (email, no card) and pass it as RASD_LICENSE at build time or createLicense({ token }). In production, refresh through your backend so devices never call Rasd (spine §9):
// src/rasd.ts (web or native)
import { createLicense } from '@rasd/license';
export const license = createLicense({ tokenEndpoint: '/api/rasd-license', storage }); // → <RasdProvider license={license}>
The SDK POSTs { current, sdk, app } to tokenEndpoint when < 14 days remain (jittered, never blocking render). A Hono proxy on your server, holding the org secret (15 §7):
// server/rasd-license.ts (Node ≥ 22 / Hono 4)
import { Hono } from 'hono';
const app = new Hono();
let cache: { token: string; serverTime: string; fetchedAt: number } | null = null; // one token serves every device for ≤ 24 h
app.post('/api/rasd-license', async (c) => {
if (!(await isAuthenticatedStaff(c))) return c.json({ error: 'unauthorized' }, 401); // your session/bearer check
if (cache && Date.now() - cache.fetchedAt < 24 * 3600_000) return c.json({ status: 'ok', ...cache });
const body = await c.req.json<{ current: string | null; sdk: string; app: string }>();
const r = await fetch('https://license.rasd.dev/v1/tokens/refresh', {
method: 'POST', headers: { authorization: `Bearer ${process.env.RASD_ORG_SECRET}`, 'content-type': 'application/json' },
body: JSON.stringify({ current: body.current, app: 'https://forms.example.org', sdk: body.sdk }),
});
const data = await r.json(); // { status: 'ok'|'lapsed'|'revoked'|'rotate', token?, serverTime }
if (data.status === 'ok' || data.status === 'rotate') cache = { token: data.token, serverTime: data.serverTime, fetchedAt: Date.now() };
return c.json(data, r.status as 200);
});
export default app;
@rasd/server/license ships this handler ready-made for Hono, Express and Next route handlers, plus the X-Rasd-License middleware that piggy-backs a fresh token on RSP responses (zero extra device requests).
sequenceDiagram
participant D as Device SDK
participant H as Your backend tokenEndpoint
participant R as license.rasd.dev
D->>H: POST /api/rasd-license with current, sdk, app
alt token cached under 24 h
H-->>D: 200 status ok, token, serverTime
else cache stale
H->>R: POST /v1/tokens/refresh with org secret
R-->>H: 200 status, token, serverTime
H-->>D: same body
end
D->>D: verify Ed25519 offline, apply only if exp is not earlier than current
Verify with rasd license check --env RASD_LICENSE --app https://forms.example.org (exit 0 ok / 2 limited / 3 invalid). Remember the invariants: limited(soft) only adds a watermark, hard blocks new submissions only; drafts, sync and storage.export() always work.
- Fake clock (
createLicense({ clock })) day 0 → 60 → 90:active→grace→limited; a refreshed token restoresactivewithin one render.
8. Quickstart 7 — custom theme, per-part overrides, Tailwind mode
A theme is JSON that extends a built-in and overrides tokens (12, schema/rasd-theme.schema.json, full example examples/theme-agency-blue.json):
// src/theme.json
{ "$schema": "https://schemas.rasd.dev/theme/v1.json", "id": "acme-field", "name": "ACME Field", "extends": "rasd-field", "mode": "light",
"tokens": { "color": { "primary": "#0B5FA5", "onPrimary": "#FFFFFF", "focus": "#0B5FA5" },
"typography": { "fontFamily": "'Source Sans 3', system-ui", "fontFamilyRtl": "'Noto Sans Arabic', system-ui", "baseSize": 17 },
"control": { "minTouch": 52, "height": 52 } },
"components": { "Button": { "radius": "full" }, "SelectOne": { "variant": "buttons" } } }
import { createTheme } from '@rasd/themes';
import themeJson from './theme.json';
const theme = createTheme(themeJson, { extends: 'rasd-field' }); // validates, resolves {color.primary} references, fills defaults
<RasdProvider theme={theme}>…</RasdProvider> // modes (dark, high contrast, density) resolve from OS unless you pass `mode`
Tokens become --rasd-color-primary etc. on .rasd-root (never :root), so plain CSS also works: .rasd-root { --rasd-radius-md: 2px }. Per-part hooks — every part has class="rasd-<Component>__<part>" and data-part, and classNames/styles/render accept values or (state) => value:
<FormRenderer definition={pdm}
classNames={{ TextField: { input: 'font-mono', root: (s) => (s.invalid ? 'shake' : '') } }}
styles={{ Button: { primary: { letterSpacing: '0.02em' } } }} />
Tailwind mode: set unstyled on the provider (the CSS file is not needed; semantic classes, data-part and ARIA remain) and style parts with utilities or with @apply against the stable class names — e.g. .rasd-TextField__input { @apply rounded-md border border-slate-300 px-3 py-2 focus:ring-2 }. Fonts are theme assets that must work offline: self-host WOFF2 (assets.fonts in the theme) and precache them. Run rasd theme check src/theme.json — it validates the JSON and lints WCAG contrast (onPrimary/primary ≥ 4.5:1).
9. Quickstart 8 — custom element type with a builder plugin
Register x:beneficiary-lookup in the renderer registry; the same defineElement() carries builder metadata so the inspector is generated for free (06 §5, 08 §11):
import { z } from 'zod';
import { defineElement, createRegistry, FieldWrapper, type ElementComponent } from '@rasd/react';
import type { BuilderPlugin } from '@rasd/builder';
type Beneficiary = { id: string; name: string };
const BeneficiaryLookup: ElementComponent<Beneficiary> = ({ field, element }) => (
<FieldWrapper field={field}>
<input {...field.inputProps} inputMode="numeric" dir="ltr" value={field.value?.id ?? ''}
onChange={(e) => field.setValue(lookup(e.target.value, element.props?.dataset as string))} /> {/* lookup(): your in-memory index of the dataset rows (storage.datasets.query cached at mount); async → setValue later is fine */}
</FieldWrapper>
);
export const beneficiaryLookup = defineElement({
type: 'x:beneficiary-lookup', component: BeneficiaryLookup,
valueSchema: z.object({ id: z.string(), name: z.string() }),
builder: { icon: 'search-user', label: { en: 'Beneficiary lookup', ar: 'بحث عن مستفيد' },
inspector: [{ kind: 'dataset', key: 'props.dataset', label: { en: 'Dataset' }, required: true }] },
});
export const registry = createRegistry({ elements: [beneficiaryLookup] });
export const acmePlugin: BuilderPlugin = { id: 'org.acme.monitor', elements: [beneficiaryLookup] };
Use <RasdProvider registry={registry}> for rendering and <FormBuilder plugins={[acmePlugin]}> for authoring. In the RFD the element is { "type": "x:beneficiary-lookup", "name": "beneficiary", "props": { "dataset": "beneficiaries" } }; devices without the registration render the placeholder card and round-trip the value — never a crash. Rules: render through FieldWrapper (label id, aria-describedby, error state), keep the value JSON-serialisable, and put anything vendor-specific in ext["org.acme.monitor"].
10. Quickstart 9 — custom REL function
import { registerFunction } from '@rasd/core';
registerFunction('acme_ration', (hhSize: unknown, ctx) => Math.min(Number(hhSize ?? 0), 8) * ctx.host.rationKg,
{ pure: true, minArgs: 1, maxArgs: 1, returns: 'number' });
Then in the form: "calculate": "acme_ration(${hh_size_total})". Names match ^[a-z][A-Za-z0-9_]*$ and should carry a vendor prefix; core names cannot be redefined; functions must be synchronous, side-effect free when pure: true, and receive frozen values plus a read-only ctx (locale, meta, host from createFormEngine(def, { host }) or the provider's registry.functions). Declare them when validating in CI — validateFormDefinition(def, { functions: ['acme_ration'] }) — because an unknown function at load is RASD_EXPR_UNKNOWN_FUNCTION and the form refuses to open (05 §8). Server lookups belong in datasets (pulldata()) or a custom element, never in an expression.
11. Quickstart 10 — plain HTML site: script tag / <rasd-form>
Self-host the versioned IIFE (it bundles React 19, the renderer, Dexie storage, sync and license) and use the custom element (11 §11):
<script src="/vendor/rasd/1.4.2/rasd-forms.iife.js" integrity="sha384-…" crossorigin="anonymous"
data-api="https://forms.example.org" data-locale="ar" data-license="eyJhbGciOiJFZERTQSIs…"></script>
<style>rasd-form { display:block; min-height:60vh; --rasd-color-primary:#0B5FA5 }</style>
<rasd-form definition-url="/forms/pdm-food-distribution.form.json" locale="ar" dir="rtl"></rasd-form>
<script>
Rasd.ready.then(() => {
const el = document.querySelector('rasd-form');
el.addEventListener('rasd:finalize', (e) => console.log('finalized', e.detail.submission.id));
el.addEventListener('rasd:error', (e) => console.error(e.detail.error.code));
});
</script>
Attributes: definition-url, definition-id, submission-id, locale, dir, theme, license, api, namespace, read-only, validate-on; object properties definition, theme, registry; events rasd:ready|change|save|finalize|error|sync. Styles live inside the open Shadow DOM, tokens inherit through it, popovers portal inside it. React apps should prefer @rasd/react (one React, tree-shaking); @rasd/element is the npm form of the same element. CSP: script-src 'self', worker-src 'self', img-src 'self' blob:, no 'unsafe-eval'. Never embed via cross-origin iframe for offline work — storage is partitioned and Safari gives frames 10 % quota (research/05 §7).
12. Quickstart 11 — import an XLSForm
pnpm dlx @rasd/cli convert xlsform ./pdm_kobo.xlsx -o src/forms/pdm.form.json # exit 1 on errors, 0 with warnings
pnpm dlx @rasd/cli convert xlsform ./pdm_kobo.xlsx -o src/forms/pdm.form.json --strict # CI gate: W_XPATH_UNMAPPED becomes an error
pnpm dlx @rasd/cli validate src/forms/pdm.form.json
label::Arabic (ar) / label::English (en) headers become BCP-47 keys (ar, en) automatically; the original header strings are recorded in ext["org.getodk.xlsform"].languages so export reproduces them. Programmatically (20):
import { importXlsform } from '@rasd/xlsform';
import { RasdError } from '@rasd/core';
try {
const { definition, warnings } = await importXlsform(await file.arrayBuffer()); // structural errors throw RasdError (RASD_XLSFORM_IMPORT)
warnings.filter((w) => w.code === 'W_XPATH_UNMAPPED').forEach((w) => console.warn(w.sheet, w.row, w.column, w.path, w.message));
await storage.forms.put(definition);
} catch (e) {
if (e instanceof RasdError && e.code === 'RASD_XLSFORM_IMPORT') showImportErrors(e.details); // { sheet, row, column }
else throw e;
}
What maps: survey/choices/settings sheets, label::Lang (code) columns, relevant/constraint/calculation/choice_filter (XPath → REL, including ${x}, selected(), if(), count(), ../, hyphenated ODK function names such as string-length( and selected-at(), begin_repeat, begin_group, Kobo rank/kobomatrix. ODK meta rows (start, end, deviceid, username) are not elements — they are rewritten to ${meta.startedAt}, today(), ${meta.deviceId}, ${meta.username}. Unmappable expressions are kept verbatim in ext["org.getodk.xpath"] with a cell-level W_XPATH_UNMAPPED warning and the property is left empty; the original row survives in ext["org.getodk.xlsform"] so re-export (exportXlsform(def, { target: 'kobo' })) is lossless. Expect the large majority of real forms to import with zero errors; review each warning in the builder's Logic panel before publishing.
-
rasd validateon the converted file exits 0; opening it in<FormBuilder>shows everyW_XPATH_UNMAPPEDinline in the Logic panel. -
rasd convert rfd src/forms/pdm.form.json --to xlsform --target koboround-trips the untouched sheets byte-for-byte (property-based test in the acceptance criteria of 20 · Interoperability).
13. Quickstart 12 — tests with @rasd/testing
// vitest.config.ts: environment 'jsdom', setupFiles ['fake-indexeddb/auto', 'vitest-axe/extend-expect']
import { test, expect } from 'vitest';
import { renderForm, fakeStorage, fakeClock } from '@rasd/testing';
import { axe } from 'vitest-axe';
import type { FormDefinition } from '@rasd/core';
import pdmJson from '../src/forms/pdm-food-distribution.form.json';
const pdm = pdmJson as FormDefinition;
test('declined consent hides the distribution questions', async () => {
const f = await renderForm(pdm, { locale: 'ar', storage: fakeStorage(), clock: fakeClock() });
await f.fill('consent', { granted: false }); // consent value is an object (spine §4.3); pages are relevant on ${consent}.granted = true
expect(f.engine.getState().fields.get('food_received')?.relevant).toBe(false); // ODK semantics: hidden, not validated, excluded on finalize
expect(f.queryByLabelText(/هل استلمت أسرتك الغذاء/)).toBeNull(); // f spreads Testing Library's render result; locale is 'ar' here
const r = await f.finalize(); // Result<Submission, ValidationResult>
if (r.ok) expect(r.value.data).not.toHaveProperty('food_received'); // irrelevant values are stripped from the finalized submission
});
test('autosave lands within autosaveMs', async () => {
const f = await renderForm(pdm, { storage: fakeStorage(), clock: fakeClock() });
await f.fill('adults', 5); f.clock.advance(2_000); await f.clock.tick(); // advance the debounce, then flush pending IndexedDB writes
expect((await f.submission()).data.adults).toBe(5);
expect((await f.submission()).status).toBe('draft');
});
test('has no a11y violations', async () => { const f = await renderForm(pdm); expect(await axe(f.container)).toHaveNoViolations(); });
(Field names follow the example form; adjust if your copy differs.)
renderForm() wraps <RasdProvider><FormRenderer/></RasdProvider> with Testing Library + user-event and returns the Testing Library render result plus { engine, storage, clock, field(name) /* the rendered element */, fill(name, value), next(), prev(), finalize() /* Result<Submission, ValidationResult> */, expectError(name, matcher), submission(), kill(), restart() } — kill()/restart() simulate process death and reopen the same fake storage, which is how the "resume a draft after a crash" test is written; engine-level facts (relevant, required, errors) come from f.engine.getState().fields.get(name). Add media: fakeMedia({ camera: { files: [jpeg] }, geolocation: { fixes: [...] } }) for capture questions and network: faultyNetwork() for sync tests; every fake throws RasdErrors with the production codes, so the troubleshooting table below applies to tests too. On native use Jest + @react-native/jest-preset + RNTL 14 with renderForm(def, { platform: 'native' }). For end-to-end, Playwright with context.setOffline(true) and a chromium-ar-rtl project on web, Maestro flows on Expo (18).
14. Before you ship — security, accessibility and performance checklist
The recipes above are deliberately minimal. Before a field deployment, walk this list once per app; every item maps to a normative rule elsewhere and most are verifiable with rasd doctor, rasd theme check or the @rasd/testing suite.
Security & data protection (16, 09 §8)
- Encryption at rest is on: web passes
encryptionKey(deriveKeyFromPassphrase()or a host-wrapped key), native uses SQLCipher (useSQLCipher: true, key in SecureStore/Keychain/Keystore — never MMKV/AsyncStorage);storage.securityReport().encryption !== 'none'on real devices and the red console warning is gone. -
bind.sensitive: trueis set on every PII question in every form (name, phone, ID numbers, precise geopoints where policy requires);settings.encryption.modeissubmissionwhen the server operator must not read answers. - Sync runs over TLS only (
policy.requireHttps: trueoutside dev); the host bearer token fromgetAuthToken()lives in memory (web) or SecureStore (RN), neverlocalStorage; a 401 flow ends in re-login +sync.resume(). - CSP is strict —
default-src 'self', no'unsafe-eval'(REL never needs it),worker-src 'self',img-src 'self' blob:,connect-srclimited to your RSP origin, tus origin andtokenEndpoint; the IIFE, if used, is self-hosted with SRI and versioned URLs. - Devices never contact Rasd origins in production: the network capture of a release build shows only your origins; the license refreshes through
tokenEndpointorX-Rasd-License. - Backup exclusion is active on native (config plugin
backupExclusion: true); logout uses two-step confirmation beforestorage.wipe()and neverClear-Site-Datawhilependingcounts are non-zero. - Forms are served from a dedicated origin without analytics or tag managers — anything on the form origin can read decrypted answers in memory.
Accessibility & i18n (13, 06 §13, 07 §14)
- Every custom element renders through
FieldWrapper(or replicates its contract: label id,aria-describedby= hint + error,aria-invalid,aria-required) — this is what keepsvitest-axeat 0 violations forx:*types. - Touch targets stay ≥ 48 px (
control.minTouch;rasd-fieldkeeps 48 even incompactdensity, the §8 theme raises it to 52) and text contrast ≥ 4.5:1 —rasd theme checkfails the build below 24 px / on failing contrast pairs; high-contrast and reduced-motion modes come from OS signals unless you forcemode. - Arabic and any other RTL locale render with
dir="rtl"from the locale alone (no hostI18nManagerrestart), mirrored icons and Arabic-Indic digit input normalised to ASCII on save; run the Storybook/Playwrightchromium-ar-rtlmatrix orrenderForm(def, { locale: 'ar' })in CI. - Font scaling to 200 % (
typography.maxFontScale,allowFontScaling) does not clip labels or hide the finalize button; keyboard-only and screen-reader passes (NVDA/VoiceOver on web, TalkBack/VoiceOver on native) are in the release checklist. - Every drag interaction in the builder has its non-drag equivalent (⋯ menu: move up/down/into) — required for WCAG 2.2 SC 2.5.7 and already true for the shipped builder; keep it true for your
paletteGroups/plugins.
Performance & offline robustness (06 §10, 07 §15, 11 §10, spine §12 budgets)
- Bundle budgets hold on your build: form-runner (core + react + storage-dexie + sync) ≤ 120 kB min+gzip excluding React; the builder is a lazy chunk; heavy element types (matrix, geo, signature, media) load on demand and are picked up by your precache glob.
- Renderer budgets hold with your registry and theme: keystroke → paint ≤ 16 ms mid-range / ≤ 50 ms on the reference device (Android 7, 1 GB RAM, 4× CPU throttle), first render of a 500-question paged form ≤ 1 s to interactive;
useFieldre-renders only the edited field and its REL dependents (React Profiler check on the 500-question fixture). - Offline first-run works: shell,
@rasd/react/styles.css, theme fonts (self-hosted WOFF2/TTF), choice images and lazy element chunks are precached;precacheForms({ maxBytes })runs after login/formUpdated;navigator.storage.persist()was requested from a user gesture andestimate().persistedistrueon installed PWAs. - Storage pressure is handled:
RASD_STORAGE_QUOTAshows a banner, synced attachments purge (retention.purgeSyncedAfterDays), andstorage.estimate()is visible in a settings/diagnostics screen alongside last sync time and pending counts. - Update safety: a new deploy while a draft is dirty produces
applyUpdate() === 'deferred'and no reload; older cached JS against a newer database opens read-only (RASD_STORAGE_DOWNGRADE, 09 §6) rather than corrupting anything. - Sync engine is a singleton per app (
createSyncEngine()once at module level, Web Locks leader on web); the UI never awaits the network to render a form.
15. Troubleshooting
| Symptom | Cause → fix |
|---|---|
RASD_SCHEMA_INVALID at load (details.path = /pages/0/elements/3/type) | Type misspelled or x: prefix missing; run rasd validate form.json. |
RASD_EXPR_PARSE on ${household size} | Names match ^[a-zA-Z_][a-zA-Z0-9_]*$; write ${hh_size}. |
RASD_EXPR_CYCLE / RASD_EXPR_UNKNOWN_FUNCTION | Mutually dependent calculates (break the cycle or use once()) / host function not registered on this registry (§10). |
| A question shows "This question needs an app update" | Unknown x:* type — register it via defineElement() in the registry. |
| Red console warning "storage is unencrypted" | Pass encryptionKey (deriveKeyFromPassphrase() on web, SecureStore key on native). |
RASD_STORAGE_QUOTA banner | Check storage.estimate(); cap precacheForms({ maxBytes }); sync more often so synced attachments purge. |
RASD_STORAGE_DOWNGRADE, storage read-only (called RASD_STORAGE_VERSION_AHEAD in 11 §5.2 — same condition) | Older cached JS against a newer database — apply the pending SW update when not busy; drafts stay visible and exportable meanwhile. |
RASD_STORAGE_KEY_UNAVAILABLE on native | Backup restore / Keystore reset; show your onStorageError fallback, never open unencrypted. |
Sync parked in authRequired (RASD_SYNC_AUTH) | getAuthToken({ forceRefresh: true }) still yields a rejected token; re-login, then sync.resume(). |
Sync offline although online | Probe to /v1/ping fails: CORS, connect-src, or requireHttps against an http dev server. |
Item rejected after sync | Server 4xx { code, message, field } — surface it; user fixes and re-finalizes. |
| Duplicate uploads from two tabs | Two engine instances — call createSyncEngine() once at module level. |
License limited on staging | apps[] lacks the staging origin (list apex and https://*.example.org separately); WebView schemes are not dev origins. |
RASD_LICENSE_INVALID unknown_kid | Upgrade @rasd/license (new signing keys ship in minor releases). |
| Camera/GPS falls back to file input on web | No media prop — pass import { web as media } from '@rasd/media'. |
| Metro: "Unable to resolve @rasd/react" | Use @rasd/native; the platform split is by package, not file extension. |
Jest: Cannot use import statement | Packages are ESM-only; use Vitest on web or @react-native/jest-preset with transformIgnorePatterns allowing @rasd/*. |
| Vite PWA: builder chunk fails precache (> 2 MB) | Raise maximumFileSizeToCacheInBytes or exclude the builder chunk from the glob. |
rasd doctor (CLI and the playground dev panel) prints storage estimate, persistence, migration state, license state and outbox age in one screen — attach it to bug reports.
16. FAQ
- Does my production app call Rasd servers? No. Verification is offline (embedded Ed25519 key); refresh goes through your
tokenEndpointor your RSP server'sX-Rasd-Licenseheader. OnlysiteKeymode calls Rasd, and it warns outsidelocalhost. - What happens when the subscription lapses?
grace(30 days) thenlimited: watermark (soft) or no new submissions (hard). Drafts finish, sync continues,storage.export()always works. - Can I use React 18?
@rasd/reactis tested against 18.3, but the peer is^19and every recipe assumes 19. - Expo web / react-native-web?
@rasd/react+@rasd/storage-dexie, plusworkbox-cliwithrasdWorkboxConfig()afterexpo export -p web(11 §3.5). - How do I ship a new form version without losing drafts? Publish a new immutable
version;diffDefinitions()yields a plan; drafts migrate opt-in viamigrateSubmission(); finalized items are never migrated and the server accepts any published version. - Can I run everything on my own infrastructure? Yes:
@rasd/server(RSP + license proxy) is self-hostable, and an offlinerasd-license.rltfile removes the last outbound call for air-gapped deployments. - Where do custom fields and KPIs live? In
ext["org.yourslug"]at any node; Rasd validates it is an object and round-trips it untouched. - Is REL evaluated with
eval? Never — Pratt parser plus sandboxed evaluator; strict CSP without'unsafe-eval'is tested in CI. - Which packages are open source and which need a token?
@rasd/core,@rasd/xlsform,@rasd/testing,@rasd/cliand@rasd/themesare Apache-2.0; the renderers, builder, storage adapters, sync, PWA, media, license and server packages are source-available (FSL-1.1-Apache-2.0) and need a valid RLT for production use after the 7-day trial (spine §12; the per-package gating policy is a configuration of the token'sfeatures[], see 15). - Do I need a storage adapter just to try the renderer? No —
<RasdProvider>withoutstoragefalls back toMemoryStoragefrom@rasd/storagewith a dev warning; drafts vanish on reload, so every recipe here passes a real adapter. - Can I write REL expressions in the builder as I would in XLSForm? Mostly yes:
${name}references,selected(),if(),count(),../,and/or/not(),=and the hyphenated ODK names in call position (string-length(,selected-at(,count-selected() all parse unchanged; XPath axes andinstance()node-sets do not (05 §16). - How do I mark which questions are PII? Set
bind.sensitive: true— the single PII flag (04 §9, 16 §2): values are masked in read-only views and the "sent" list, redacted frominstanceName, logs and crash reports, field-encrypted at rest when whole-DB encryption is unavailable, and E2E-encrypted whensettings.encryption.modeisfield/submission;rasd validatewarnsW_SENSITIVE_WITHOUT_ENCRYPTIONwhen sensitive elements exist butsettings.encryption.modeisnone, andW_INDEX_ON_SENSITIVEwhen you index one (index columns are cleartext). - What if a device stays offline for months? Sync is opportunistic and the outbox is durable; the license rides out
exp+grace(about 90 days on monthly plans) beforelimited, and even then drafts finish, sync continues and export works — field data is never lost because of licensing.
17. Upgrade guide (skeleton)
All runtime packages (@rasd/core, @rasd/react, @rasd/native, @rasd/storage*, @rasd/sync, @rasd/media, @rasd/pwa, @rasd/license, @rasd/themes, @rasd/element, @rasd/builder) are released as a fixed-version group on a 4-week train — upgrade them together (pnpm up "@rasd/*"@latest); tooling packages (@rasd/cli, @rasd/xlsform, @rasd/testing, @rasd/server) version independently (19). Per release, the changelog carries the following headings; each MAJOR ships a codemod (rasd migrate <from> <to>, 18 §8) after a deprecation release with a runtime warning, and removals come no earlier than 12 months after the deprecation.
| Section | Contents |
|---|---|
| Compatibility matrix | React / RN / Expo / Node / browsers this release was tested against (regenerated from CI). |
RFD spec (rasd version) | New properties (ignore-and-preserve within a MAJOR), deprecations (≥ 12 months notice), converter for MAJOR bumps. |
| Storage schema | Dexie/SQLite version bump? Additive only in a MINOR; forward-only migrations run in open(); older code opens newer DBs read-only. |
| RSP | New endpoints/fields only within /v1; server before clients. |
| License | New signing kids (upgrade @rasd/license before keys rotate); token features[] additions. |
| Theme tokens / CSS parts | Added or renamed --rasd-* variables and data-part names; rasd theme check flags removed tokens. |
| Public API | Deprecated exports (warn one MINOR, remove next MAJOR); codemod coverage. |
| Steps | 1) read the matrix, 2) upgrade the group, 3) rasd validate your forms + rasd theme check, 4) run @rasd/testing suite, 5) deploy server before clients, 6) roll out with the SW update prompt (never forced while busy). |
18. Acceptance criteria for these recipes
- Every code block in this document exists, unchanged, in
apps/playground-web,apps/example-next,apps/example-expoorapps/docsand is executed in CI (typecheck + the checklist assertions above). - A developer with no prior exposure completes §2 in ≤ 15 minutes and §2–§4 in ≤ 60 minutes on a clean machine (timed in onboarding sessions).
- The Vite recipe passes the offline checklist in 11 §10 and the strict-CSP run.
- The Expo recipe passes the Maestro
offline-fill.yamlandresume-draft.yamlflows on an API 24 emulator. -
rasd doctoroutput is documented with a screenshot in the docs site (en/ar). - Every
RASD_*error code andW_*warning code named in §14–§15 exists in the error registry of 17 · API reference / 04 · Form schema spec (a docs-lint job fails on unknown codes). - Every machine-checkable item in §14 is covered by
rasd doctor,rasd theme check,size-limitor the@rasd/testing/Playwright suites; the manual items (screen-reader pass, dedicated origin, logout policy) appear on the docs-site release checklist (en/ar). - The XLSForm recipe imports the pyxform
xlsform_spec_test.xlsxandchoice_filter_test.xlsxfixtures used by the corpus job in 20 with zero errors and lists everyW_XPATH_UNMAPPEDwith sheet/row/column.
Open questions
- Should we publish a
create-rasdscaffold (pnpm create rasd@latest --template vite|next|expo|html) that generates these files, or keep the recipes copy-paste only until the API stabilises? - Do we expose a
useStorage()hook so components likeDraftListdo not import the module-level adapter (currently the pattern in 06 §19)? - Should
requireHttpsexempt loopback origins automatically so the Vite quickstart needs nopolicyoverride? - Is
pnpm dlx @rasd/clithe primary CLI invocation, or do we also publish arasdbin package fornpx rasd? - Should the docs site host the IIFE on
cdn.rasd.devfor CodePen-style demos given the "self-host in production" rule? renderForm().field(name)returns the rendered element (17 · API reference); tests keep reaching forengine.getState().fields.get(name)forrelevant/errors— should@rasd/testingadd astate(name): FieldStatehelper so recipes read naturally?- The XLSForm importer's return shape is
{ definition, warnings, report }in 20 and{ definition, warnings, unmapped }in 17; this recipe only destructures the common{ definition, warnings }until that is reconciled. - The old-code/new-database condition is
RASD_STORAGE_DOWNGRADEin 09/17 butRASD_STORAGE_VERSION_AHEADin 11 §5.2; one name should win before the troubleshooting table is published.
Related documents
00 · Decisions & conventions · 01 · Vision & scope · 02 · Requirements · 03 · Architecture · 04 · Form schema spec · 05 · Logic & expressions · 06 · Renderer (React) · 07 · Renderer (native) · 08 · Builder · 09 · Offline storage · 10 · Sync protocol · 11 · PWA & embedding · 12 · Theming · 13 · i18n, RTL & accessibility · 14 · Media & field capture · 15 · Licensing & billing · 16 · Security & data protection · 17 · API reference · 18 · Engineering practices · 19 · Roadmap & work breakdown · 20 · Interoperability · Schemas: rasd-form.schema.json, rasd-theme.schema.json · Examples: theme-agency-blue.json, examples/*.form.json · Research: 05 · PWA & embedding, 08 · Library engineering