@fias/arche-sdk 2.33.0 → 2.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.33.0 -->
1
+ <!-- fias-sdk-guide-version: 2.34.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -21,19 +21,19 @@ vite.config.ts # Vite dev server config (port 3100)
21
21
 
22
22
  Decide by who writes the data and when, before you write the code: moving data to a different home after you publish means a migration.
23
23
 
24
- | What it is | Where it goes | What you get |
25
- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
- | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
- | A few static images, fonts or music tracks your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
- | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
- | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
- | Sound effects that must play on cue (hits, jumps, UI clicks) | The bundle: base64 in `src/`, decoded once at startup with `decodeAudioData` | Ready the moment the plugin opens; nothing to download per session. Keep the set small (a few hundred KB). Not `public/`: a `public/` sound can only play through an `<audio>` element, which lags its cue (badly on iOS). |
31
- | The same sound effects shared by several arches (optional) | A workspace asset library: `useArcheAssetBytes().getMany()` | One copy for every linked arche; a new version reaches them with a pin refresh, no code republish. Downloaded each session — wait for it before the first cue. MP3 or WAV, ≤ 1 MB and 30 s each. |
32
- | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
33
- | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
34
- | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
35
- | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
36
- | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
24
+ | What it is | Where it goes | What you get |
25
+ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
+ | A few static images, fonts or music tracks your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
+ | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts, 3D models | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
+ | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
+ | Sound effects that must play on cue (hits, jumps, UI clicks) | The bundle: base64 in `src/`, decoded once at startup with `decodeAudioData` | Ready the moment the plugin opens; nothing to download per session. Keep the set small (a few hundred KB). Not `public/`: a `public/` sound can only play through an `<audio>` element, which lags its cue (badly on iOS). |
31
+ | The same sound effects shared by several arches (optional) | A workspace asset library: `useArcheAssetBytes().getMany()` | One copy for every linked arche; a new version reaches them with a pin refresh, no code republish. Downloaded each session — wait for it before the first cue. MP3 or WAV, ≤ 1 MB and 30 s each. |
32
+ | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
33
+ | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
34
+ | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
35
+ | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
36
+ | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
37
37
 
38
38
  **Common mistakes:**
39
39
 
@@ -1615,7 +1615,7 @@ for (const sound of entries) {
1615
1615
  **Permission:** `entities:client_invoke`
1616
1616
  **Returns:** `FiasContentApi`
1617
1617
 
1618
- Ship large or numerous files — lessons, articles, data, images, audio, fonts — in a `content/` directory beside `src/`. They are not bundled: `fias-dev submit` uploads them separately (only the files that changed), the review reads every one, and the published plugin reads them at runtime through this hook. The host page fetches, verifies and caches them; your plugin never touches the network.
1618
+ Ship large or numerous files — lessons, articles, data, images, audio, fonts, 3D models — in a `content/` directory beside `src/`. They are not bundled: `fias-dev submit` uploads them separately (only the files that changed), the review reads every one, and the published plugin reads them at runtime through this hook. The host page fetches, verifies and caches them; your plugin never touches the network.
1619
1619
 
1620
1620
  ```tsx
1621
1621
  import { useFiasContent, FiasContentError } from '@fias/arche-sdk';
@@ -1630,7 +1630,7 @@ const cover = await content.getObjectUrl('img/cover.webp'); // blob: URL for <im
1630
1630
  const { url } = await content.getUrl('audio/theme.ogg'); // signed URL, for long streaming audio
1631
1631
  ```
1632
1632
 
1633
- - Paths are relative to `content/`. Formats: `.md`, `.txt`, `.json` (text, ≤ 5 MB each); `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.avif` (static, single-frame images, ≤ 20 MB and 50 megapixels); `.ogg` (audio, ≤ 50 MB); `.woff2` (fonts, ≤ 5 MB). A pack holds at most 10,000 files, 2,000 images, 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1633
+ - Paths are relative to `content/`. Formats: `.md`, `.txt`, `.json` (text, ≤ 5 MB each); `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.avif` (static, single-frame images, ≤ 20 MB and 50 megapixels); `.ogg` (audio, ≤ 50 MB); `.woff2` (fonts, ≤ 5 MB); `.glb` (3D models, ≤ 25 MB, read with `getBytes` — see "VR and AR apps (WebXR)" for what a model may hold). A pack holds at most 10,000 files, 2,000 images (counting the textures inside models), 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1634
1634
  - Prefer `getObjectUrl` for images and fonts: repeat views come from the host cache. `getUrl` is for media that must stream or seek; it bypasses the cache, and a URL held past its `expiresAt` must be requested again.
1635
1635
  - Failures throw `FiasContentError` with a `code`: `CONTENT_NOT_FOUND`, `CONTENT_NOT_TEXT`, `CONTENT_TOO_LARGE`, `CONTENT_UNAVAILABLE`, `CONTENT_THROTTLED`, `CONTENT_UNAVAILABLE_IN_PREVIEW`, `PERMISSION_DENIED`, `RATE_LIMIT`. `getMany` returns the error for a failed path instead of throwing.
1636
1636
  - There is no published pack in the Arche Builder preview, so calls there fail with `CONTENT_UNAVAILABLE_IN_PREVIEW`; the dev harness serves your local `content/` directory.
@@ -1860,7 +1860,9 @@ const result = await web.extract({
1860
1860
 
1861
1861
  Your app reads pages only on **websites Fias approved** (regulated institutions such as credit unions and banks, and government sites) — any page on an approved site, so a redesigned or moved page keeps working. To have a site approved, ask Fias. `listSites()` lists them (free).
1862
1862
 
1863
- `extract()` never returns the page's text — not even a short label. You send a **form** — up to 30 fields of kinds `number` and `integer` (optional `min`/`max`), `boolean`, `date` (`YYYY-MM-DD`), `enum` (your own `values`) and `list` (up to 200 rows of those) — and Fias reads the page into it. There is no free-text kind: describe what you need as numbers, dates, yes/no and choices you list (a product type, not a product name). Every value may be `null` (the page did not say); a value that broke its field's rules comes back `null` and is named in `discarded`. A model reading a page is not deterministic, so **where a wrong number matters (money), pass `reads: 2`**: the page is read twice, independently, and only a value both readings agree on is returned — a field they disagree on is `null` (a list keeps only the rows both produced) and is named in `disagreed`, for a person to confirm. A list named in `disagreed` may be missing rows the page prints: never conclude "absent on the page" (not offered) from a field in `disagreed` — treat it as unknown. Agreement says a value was stable across two readings, not that it is right; always show `site.name` and `fetchedAt` beside a figure so a person can check it. With two readings `discarded` names a list whole (`rates`), not a row. Two readings are two reader charges; the platform may raise the floor for every app, so check `disagreed` whatever you asked for. The reader's model, and so a reading's price, is chosen by the platform. URLs must be `https://`; `redirected: true` means the site moved the page within itself (the new address is not returned). The reader fills the form from whatever layout the page has, but it never runs JavaScript: a page that builds its figures with scripts comes back with `scriptHeavy: true` and mostly nulls — read the site's PDF rate sheet instead.
1863
+ `extract()` never returns the page's text — not even a short label. You send a **form** — up to 30 fields of kinds `number` and `integer` (optional `min`/`max`), `boolean`, `date` (`YYYY-MM-DD`), `enum` (your own `values`) and `list` (up to 200 rows of those) — and Fias reads the page into it. There is no free-text kind: describe what you need as numbers, dates, yes/no and choices you list (a heading the page prints is fine as a choice if you listed it; a string the page chose never is). Every value may be `null` (the page did not say); a value that broke its field's rules comes back `null` and is named in `discarded`. A model reading a page is not deterministic, so **where a wrong number matters (money), pass `reads: 2`**: the page is read twice, independently, and only a value both readings agree on is returned — a field they disagree on is `null` (a list keeps only the rows both produced) and is named in `disagreed`, for a person to confirm. A list named in `disagreed` may be missing rows the page prints: never conclude "absent on the page" (not offered) from a field in `disagreed` — treat it as unknown. Agreement says a value was stable across two readings, not that it is right; always show `site.name` and `fetchedAt` beside a figure so a person can check it. With two readings `discarded` names a list whole (`rates`), not a row. After the field names, `disagreed` names each row a list lost by its index in the in-order reading (`rates[3]`): the list you get is that reading with those rows removed. **Give a list a `key`** — the item fields that identify a row on the page (`key: ['product', 'months']`) — and a row both readings produced under one key is kept and agreed cell by cell: a cell they disagree on is `null` in the row and named `rates[3].apy` after the row entries, instead of the whole row being dropped because one cell differed (a row with a `null` key cell, or a key value a reading gave twice, is matched whole; the `3` is the row's index in the in-order reading, not in the list you received). `disagreedEntries(disagreed)` splits the entries into `fields`, `rows` and `cells`, each cell with its `position` in the list you received, and `disagreedFieldNames(disagreed)` gives the names alone; match entries by name or the `name[` prefix, never by equality or count. The reader never sees a key: a single reading Fias holds is paired, not bought again, when you add one; what two readings agreed on is read again. Two readings are two reader charges; the platform may raise the floor for every app, so check `disagreed` whatever you asked for. The reader's model, and so a reading's price, is chosen by the platform. URLs must be `https://`; `redirected: true` means the site moved the page within itself (the new address is not returned). The reader fills the form from whatever layout the page has, but it never runs JavaScript: a page that builds its figures with scripts comes back with `scriptHeavy: true` and mostly nulls — read the site's PDF rate sheet instead.
1864
+
1865
+ **Write the form from the page, not from what pages usually look like.** Read the page yourself first (you can; the reader cannot show it to you) and write the form from what that page prints, one per product you read off it (each is its own reading pair). List the page's own words — its table headings, its model-year bands, its term column headers — as `enum` choices on each row (up to 50 values of up to 60 characters) plus a fixed escape such as `(none of these)`, so the reader copies and never classifies; keep a small table in the app that turns each heading into the cells your code reads, and key each list by that cell and the printed term. Ask only for cells the page prints: a cell the page never prints is answered `null` by one reading and guessed by the other, whatever its description says. A page changes: rows under your escape choice, a withheld row whose key cell the readings could not agree on, the list named whole in `discarded` (with two readings it never names a row), an empty list where the page says the product is published, or far fewer rows than last time mean the form must be rewritten — tell the user which page, in plain words. On the Rate Research pilot a generic form that asked the reader to classify had 63 of 117 counted rows withheld and agreed twice on three plain misreadings; forms written from each page lost no rows on the next refresh and matched one institution's pages in 57 of 57 values. `reads: 2` stays on either way: it catches the misread digit, which no form can.
1864
1866
 
1865
1867
  One copy of each page serves every app, and a page is fetched from its site **at most once an hour**. `maxAgeSeconds` (default one day, 3600–30 days) is the oldest copy you accept. A fresh fetch (about a tenth of a cent) and each reading (a fast model's tokens — well under a cent for a typical page; two with `reads: 2`) are charged to the user; a cached result costs nothing (`fromCache: true`). `stale: true` means the site could not be asked again just now and you got the last copy. Always show `site.name` and `fetchedAt` beside the figures. Not available in the builder preview or the test channel. **Error codes:** `WEB_SOURCES_DISABLED`, `INVALID_URL`, `SITE_NOT_APPROVED`, `PAGE_EXCLUDED` (a part of the site where the public posts), `ROBOTS_DISALLOWED`, `FETCH_FAILED`, `RESPONSE_TOO_LARGE`, `CONTENT_TYPE_NOT_ALLOWED`, `EXTRACTION_FAILED`, `SOURCE_BUSY`, `RATE_LIMIT` (surface it, never retry in a loop), `INSUFFICIENT_CREDITS`, `PERMISSION_DENIED`.
1866
1868
 
@@ -2171,6 +2173,110 @@ Notes:
2171
2173
  - Only `http(s)` URLs make sense here; `javascript:`/`data:` URLs are blocked
2172
2174
  by the sandbox.
2173
2175
 
2176
+ ### VR and AR apps (WebXR)
2177
+
2178
+ A plugin can be a VR or AR app: a game, a 3D viewer, a space to walk around.
2179
+ On a headset such as a Meta Quest, the person opens your arche in the
2180
+ headset's browser and presses your "Enter VR" button. No SDK hook or
2181
+ permission is needed: the plugin frame is allowed WebXR, and you use the
2182
+ browser's own API or a library. With three.js, add `three` to the manifest's
2183
+ `dependencies` (the published arche loads it at runtime) and to `package.json`,
2184
+ with `@types/three` in `devDependencies`, all at the same exact version.
2185
+
2186
+ **Import from `three` itself, never `three/addons/...`.** The published build
2187
+ maps a dependency's bare name but not its subpaths, so `three/addons/...`
2188
+ (VRButton, OrbitControls, GLTFLoader) works under `npm run dev` and then fails
2189
+ the build with "no importmap entry". An Enter VR button is a few lines:
2190
+
2191
+ ```tsx
2192
+ import * as THREE from 'three';
2193
+
2194
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
2195
+ renderer.xr.enabled = true;
2196
+ renderer.xr.setReferenceSpaceType('local-floor'); // y = 0 is the room's floor
2197
+ container.appendChild(renderer.domElement);
2198
+
2199
+ // Offer VR only where it exists. On a phone or desktop this resolves false and
2200
+ // the same scene stays a normal 3D view.
2201
+ const vr = (await navigator.xr?.isSessionSupported('immersive-vr').catch(() => false)) ?? false;
2202
+
2203
+ // Call from the button's click handler, and request the session before any
2204
+ // other await: the click is what lets the browser start it.
2205
+ async function enterVr(xr: XRSystem) {
2206
+ const session = await xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor'] });
2207
+ await renderer.xr.setSession(session);
2208
+ }
2209
+
2210
+ // Not requestAnimationFrame: in VR the headset drives the frames.
2211
+ renderer.setAnimationLoop(() => renderer.render(scene, camera));
2212
+ ```
2213
+
2214
+ When you need an addon, copy its file from `node_modules/three/examples/jsm/`
2215
+ into `src/vendor/three/`, with the files it imports by relative path, keeping
2216
+ the folders. Its other imports are `three`, which the build maps. Beside it, add
2217
+ a one-line `.d.ts` so TypeScript uses `@types/three`'s types, for example
2218
+ `src/vendor/three/loaders/GLTFLoader.d.ts` holding
2219
+ `export * from 'three/examples/jsm/loaders/GLTFLoader.js';`.
2220
+
2221
+ **3D models.** Put each model in your content pack as a `.glb`
2222
+ (`content/models/chair.glb`) and read its bytes with `useFiasContent()` (see
2223
+ that section; it needs the `entities:client_invoke` permission). Your plugin
2224
+ cannot fetch, so `GLTFLoader.load(url)` fails, but `parseAsync` takes the
2225
+ bytes. Copy GLTFLoader in as above (`loaders/GLTFLoader.js` plus
2226
+ `utils/BufferGeometryUtils.js` and `utils/SkeletonUtils.js`) and swap its
2227
+ texture loader: three.js decodes embedded textures with `fetch()`, which the
2228
+ sandbox blocks, and the model then loads with NO textures and only a console
2229
+ warning says why.
2230
+
2231
+ ```tsx
2232
+ import * as THREE from 'three';
2233
+ import { useFiasContent } from '@fias/arche-sdk';
2234
+ import { GLTFLoader } from './vendor/three/loaders/GLTFLoader.js';
2235
+
2236
+ const content = useFiasContent();
2237
+ const loader = new GLTFLoader();
2238
+ loader.register((parser) => {
2239
+ parser.textureLoader = new THREE.TextureLoader(parser.options.manager); // loads through <img>
2240
+ return { name: 'fias_no_fetch_textures' };
2241
+ });
2242
+ const gltf = await loader.parseAsync(await content.getBytes('models/chair.glb'), '');
2243
+ scene.add(gltf.scene);
2244
+ ```
2245
+
2246
+ What a model may hold, and why:
2247
+
2248
+ - One self-contained binary glTF 2.0 file, at most 25 MB: everything packed
2249
+ inside, with no separate `.bin` or image files. A `.gltf` with side files is
2250
+ refused; Blender's glTF exporter makes the right kind with "glTF Binary (.glb)".
2251
+ - At most 64 textures, each a still PNG, JPEG, WebP or AVIF. Every texture is
2252
+ screened like an image file and counts toward the pack's 2,000 images; the
2253
+ names and `extras` in the model are reviewed like text.
2254
+ - Export without compression. KTX2 / Basis textures are refused, and Draco or
2255
+ meshopt geometry cannot be decoded in a plugin: their three.js decoders need
2256
+ WebAssembly, which the plugin's sandbox does not run.
2257
+
2258
+ A texture you apply in code can be its own image file in the pack: pass the URL
2259
+ from `content.getObjectUrl('textures/wall.webp')` to
2260
+ `new THREE.TextureLoader().loadAsync(url)`.
2261
+
2262
+ Notes:
2263
+
2264
+ - Entering VR or AR needs a click, and the browser may ask the person for
2265
+ consent. `immersive-ar` (passthrough on Quest) starts the same way: create
2266
+ the renderer with `alpha: true` and hide your sky so the room shows through.
2267
+ - Head and controller positions reveal things about the person, like their
2268
+ height. Use them to draw the scene, not as data you save or send.
2269
+ - To test on a headset with `fias-dev`, the page must be a secure context.
2270
+ `localhost` is one and a LAN address (`http://192.168.…`) is not, so connect
2271
+ the headset by USB with developer mode on, run the commands below, and open
2272
+ `http://localhost:3200` in the headset's browser. A published arche needs
2273
+ nothing extra.
2274
+
2275
+ ```bash
2276
+ adb reverse tcp:3100 tcp:3100 # your plugin's dev server
2277
+ adb reverse tcp:3200 tcp:3200 # the fias-dev harness
2278
+ ```
2279
+
2174
2280
  ### `useStepNavigation()` — Multi-step workflows
2175
2281
 
2176
2282
  **Permission:** None for in-memory use; `storage:sandbox` when `persistKey` is set (it reads/writes `__state/<persistKey>` via the storage bridge).
@@ -2461,6 +2567,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
2461
2567
  - **`window.alert()` / `confirm()` / `prompt()` do nothing** (no `allow-modals`): they don't throw — `confirm()` silently returns `false`, so gated actions never run. Build confirmation into your UI (a two-step button or an inline dialog) instead
2462
2568
  - **No `fetch()` or `XMLHttpRequest`** — all network access is blocked
2463
2569
  - **You _can_ open external links in a new tab** (e.g. `window.open('https://…', '_blank')`) — see "Opening external links" above
2570
+ - **You _can_ build VR and AR apps (WebXR)** for headsets like the Meta Quest — see "VR and AR apps (WebXR)" above
2464
2571
  - **No access** to parent DOM, cookies, or localStorage
2465
2572
  - **No external scripts or stylesheets** — everything must be bundled
2466
2573
  - All platform communication goes through the bridge (SDK hooks)
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.33.0 -->
1
+ <!-- fias-sdk-guide-version: 2.34.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -21,19 +21,19 @@ vite.config.ts # Vite dev server config (port 3100)
21
21
 
22
22
  Decide by who writes the data and when, before you write the code: moving data to a different home after you publish means a migration.
23
23
 
24
- | What it is | Where it goes | What you get |
25
- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
- | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
- | A few static images, fonts or music tracks your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
- | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
- | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
- | Sound effects that must play on cue (hits, jumps, UI clicks) | The bundle: base64 in `src/`, decoded once at startup with `decodeAudioData` | Ready the moment the plugin opens; nothing to download per session. Keep the set small (a few hundred KB). Not `public/`: a `public/` sound can only play through an `<audio>` element, which lags its cue (badly on iOS). |
31
- | The same sound effects shared by several arches (optional) | A workspace asset library: `useArcheAssetBytes().getMany()` | One copy for every linked arche; a new version reaches them with a pin refresh, no code republish. Downloaded each session — wait for it before the first cue. MP3 or WAV, ≤ 1 MB and 30 s each. |
32
- | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
33
- | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
34
- | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
35
- | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
36
- | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
24
+ | What it is | Where it goes | What you get |
25
+ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
+ | A few static images, fonts or music tracks your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
+ | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts, 3D models | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
+ | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
+ | Sound effects that must play on cue (hits, jumps, UI clicks) | The bundle: base64 in `src/`, decoded once at startup with `decodeAudioData` | Ready the moment the plugin opens; nothing to download per session. Keep the set small (a few hundred KB). Not `public/`: a `public/` sound can only play through an `<audio>` element, which lags its cue (badly on iOS). |
31
+ | The same sound effects shared by several arches (optional) | A workspace asset library: `useArcheAssetBytes().getMany()` | One copy for every linked arche; a new version reaches them with a pin refresh, no code republish. Downloaded each session — wait for it before the first cue. MP3 or WAV, ≤ 1 MB and 30 s each. |
32
+ | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
33
+ | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
34
+ | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
35
+ | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
36
+ | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
37
37
 
38
38
  **Common mistakes:**
39
39
 
@@ -1615,7 +1615,7 @@ for (const sound of entries) {
1615
1615
  **Permission:** `entities:client_invoke`
1616
1616
  **Returns:** `FiasContentApi`
1617
1617
 
1618
- Ship large or numerous files — lessons, articles, data, images, audio, fonts — in a `content/` directory beside `src/`. They are not bundled: `fias-dev submit` uploads them separately (only the files that changed), the review reads every one, and the published plugin reads them at runtime through this hook. The host page fetches, verifies and caches them; your plugin never touches the network.
1618
+ Ship large or numerous files — lessons, articles, data, images, audio, fonts, 3D models — in a `content/` directory beside `src/`. They are not bundled: `fias-dev submit` uploads them separately (only the files that changed), the review reads every one, and the published plugin reads them at runtime through this hook. The host page fetches, verifies and caches them; your plugin never touches the network.
1619
1619
 
1620
1620
  ```tsx
1621
1621
  import { useFiasContent, FiasContentError } from '@fias/arche-sdk';
@@ -1630,7 +1630,7 @@ const cover = await content.getObjectUrl('img/cover.webp'); // blob: URL for <im
1630
1630
  const { url } = await content.getUrl('audio/theme.ogg'); // signed URL, for long streaming audio
1631
1631
  ```
1632
1632
 
1633
- - Paths are relative to `content/`. Formats: `.md`, `.txt`, `.json` (text, ≤ 5 MB each); `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.avif` (static, single-frame images, ≤ 20 MB and 50 megapixels); `.ogg` (audio, ≤ 50 MB); `.woff2` (fonts, ≤ 5 MB). A pack holds at most 10,000 files, 2,000 images, 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1633
+ - Paths are relative to `content/`. Formats: `.md`, `.txt`, `.json` (text, ≤ 5 MB each); `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.avif` (static, single-frame images, ≤ 20 MB and 50 megapixels); `.ogg` (audio, ≤ 50 MB); `.woff2` (fonts, ≤ 5 MB); `.glb` (3D models, ≤ 25 MB, read with `getBytes` — see "VR and AR apps (WebXR)" for what a model may hold). A pack holds at most 10,000 files, 2,000 images (counting the textures inside models), 20 MB of text and 500 MB in total. Run `npx fias-dev content check` to validate without submitting.
1634
1634
  - Prefer `getObjectUrl` for images and fonts: repeat views come from the host cache. `getUrl` is for media that must stream or seek; it bypasses the cache, and a URL held past its `expiresAt` must be requested again.
1635
1635
  - Failures throw `FiasContentError` with a `code`: `CONTENT_NOT_FOUND`, `CONTENT_NOT_TEXT`, `CONTENT_TOO_LARGE`, `CONTENT_UNAVAILABLE`, `CONTENT_THROTTLED`, `CONTENT_UNAVAILABLE_IN_PREVIEW`, `PERMISSION_DENIED`, `RATE_LIMIT`. `getMany` returns the error for a failed path instead of throwing.
1636
1636
  - There is no published pack in the Arche Builder preview, so calls there fail with `CONTENT_UNAVAILABLE_IN_PREVIEW`; the dev harness serves your local `content/` directory.
@@ -1860,7 +1860,9 @@ const result = await web.extract({
1860
1860
 
1861
1861
  Your app reads pages only on **websites Fias approved** (regulated institutions such as credit unions and banks, and government sites) — any page on an approved site, so a redesigned or moved page keeps working. To have a site approved, ask Fias. `listSites()` lists them (free).
1862
1862
 
1863
- `extract()` never returns the page's text — not even a short label. You send a **form** — up to 30 fields of kinds `number` and `integer` (optional `min`/`max`), `boolean`, `date` (`YYYY-MM-DD`), `enum` (your own `values`) and `list` (up to 200 rows of those) — and Fias reads the page into it. There is no free-text kind: describe what you need as numbers, dates, yes/no and choices you list (a product type, not a product name). Every value may be `null` (the page did not say); a value that broke its field's rules comes back `null` and is named in `discarded`. A model reading a page is not deterministic, so **where a wrong number matters (money), pass `reads: 2`**: the page is read twice, independently, and only a value both readings agree on is returned — a field they disagree on is `null` (a list keeps only the rows both produced) and is named in `disagreed`, for a person to confirm. A list named in `disagreed` may be missing rows the page prints: never conclude "absent on the page" (not offered) from a field in `disagreed` — treat it as unknown. Agreement says a value was stable across two readings, not that it is right; always show `site.name` and `fetchedAt` beside a figure so a person can check it. With two readings `discarded` names a list whole (`rates`), not a row. Two readings are two reader charges; the platform may raise the floor for every app, so check `disagreed` whatever you asked for. The reader's model, and so a reading's price, is chosen by the platform. URLs must be `https://`; `redirected: true` means the site moved the page within itself (the new address is not returned). The reader fills the form from whatever layout the page has, but it never runs JavaScript: a page that builds its figures with scripts comes back with `scriptHeavy: true` and mostly nulls — read the site's PDF rate sheet instead.
1863
+ `extract()` never returns the page's text — not even a short label. You send a **form** — up to 30 fields of kinds `number` and `integer` (optional `min`/`max`), `boolean`, `date` (`YYYY-MM-DD`), `enum` (your own `values`) and `list` (up to 200 rows of those) — and Fias reads the page into it. There is no free-text kind: describe what you need as numbers, dates, yes/no and choices you list (a heading the page prints is fine as a choice if you listed it; a string the page chose never is). Every value may be `null` (the page did not say); a value that broke its field's rules comes back `null` and is named in `discarded`. A model reading a page is not deterministic, so **where a wrong number matters (money), pass `reads: 2`**: the page is read twice, independently, and only a value both readings agree on is returned — a field they disagree on is `null` (a list keeps only the rows both produced) and is named in `disagreed`, for a person to confirm. A list named in `disagreed` may be missing rows the page prints: never conclude "absent on the page" (not offered) from a field in `disagreed` — treat it as unknown. Agreement says a value was stable across two readings, not that it is right; always show `site.name` and `fetchedAt` beside a figure so a person can check it. With two readings `discarded` names a list whole (`rates`), not a row. After the field names, `disagreed` names each row a list lost by its index in the in-order reading (`rates[3]`): the list you get is that reading with those rows removed. **Give a list a `key`** — the item fields that identify a row on the page (`key: ['product', 'months']`) — and a row both readings produced under one key is kept and agreed cell by cell: a cell they disagree on is `null` in the row and named `rates[3].apy` after the row entries, instead of the whole row being dropped because one cell differed (a row with a `null` key cell, or a key value a reading gave twice, is matched whole; the `3` is the row's index in the in-order reading, not in the list you received). `disagreedEntries(disagreed)` splits the entries into `fields`, `rows` and `cells`, each cell with its `position` in the list you received, and `disagreedFieldNames(disagreed)` gives the names alone; match entries by name or the `name[` prefix, never by equality or count. The reader never sees a key: a single reading Fias holds is paired, not bought again, when you add one; what two readings agreed on is read again. Two readings are two reader charges; the platform may raise the floor for every app, so check `disagreed` whatever you asked for. The reader's model, and so a reading's price, is chosen by the platform. URLs must be `https://`; `redirected: true` means the site moved the page within itself (the new address is not returned). The reader fills the form from whatever layout the page has, but it never runs JavaScript: a page that builds its figures with scripts comes back with `scriptHeavy: true` and mostly nulls — read the site's PDF rate sheet instead.
1864
+
1865
+ **Write the form from the page, not from what pages usually look like.** Read the page yourself first (you can; the reader cannot show it to you) and write the form from what that page prints, one per product you read off it (each is its own reading pair). List the page's own words — its table headings, its model-year bands, its term column headers — as `enum` choices on each row (up to 50 values of up to 60 characters) plus a fixed escape such as `(none of these)`, so the reader copies and never classifies; keep a small table in the app that turns each heading into the cells your code reads, and key each list by that cell and the printed term. Ask only for cells the page prints: a cell the page never prints is answered `null` by one reading and guessed by the other, whatever its description says. A page changes: rows under your escape choice, a withheld row whose key cell the readings could not agree on, the list named whole in `discarded` (with two readings it never names a row), an empty list where the page says the product is published, or far fewer rows than last time mean the form must be rewritten — tell the user which page, in plain words. On the Rate Research pilot a generic form that asked the reader to classify had 63 of 117 counted rows withheld and agreed twice on three plain misreadings; forms written from each page lost no rows on the next refresh and matched one institution's pages in 57 of 57 values. `reads: 2` stays on either way: it catches the misread digit, which no form can.
1864
1866
 
1865
1867
  One copy of each page serves every app, and a page is fetched from its site **at most once an hour**. `maxAgeSeconds` (default one day, 3600–30 days) is the oldest copy you accept. A fresh fetch (about a tenth of a cent) and each reading (a fast model's tokens — well under a cent for a typical page; two with `reads: 2`) are charged to the user; a cached result costs nothing (`fromCache: true`). `stale: true` means the site could not be asked again just now and you got the last copy. Always show `site.name` and `fetchedAt` beside the figures. Not available in the builder preview or the test channel. **Error codes:** `WEB_SOURCES_DISABLED`, `INVALID_URL`, `SITE_NOT_APPROVED`, `PAGE_EXCLUDED` (a part of the site where the public posts), `ROBOTS_DISALLOWED`, `FETCH_FAILED`, `RESPONSE_TOO_LARGE`, `CONTENT_TYPE_NOT_ALLOWED`, `EXTRACTION_FAILED`, `SOURCE_BUSY`, `RATE_LIMIT` (surface it, never retry in a loop), `INSUFFICIENT_CREDITS`, `PERMISSION_DENIED`.
1866
1868
 
@@ -2171,6 +2173,110 @@ Notes:
2171
2173
  - Only `http(s)` URLs make sense here; `javascript:`/`data:` URLs are blocked
2172
2174
  by the sandbox.
2173
2175
 
2176
+ ### VR and AR apps (WebXR)
2177
+
2178
+ A plugin can be a VR or AR app: a game, a 3D viewer, a space to walk around.
2179
+ On a headset such as a Meta Quest, the person opens your arche in the
2180
+ headset's browser and presses your "Enter VR" button. No SDK hook or
2181
+ permission is needed: the plugin frame is allowed WebXR, and you use the
2182
+ browser's own API or a library. With three.js, add `three` to the manifest's
2183
+ `dependencies` (the published arche loads it at runtime) and to `package.json`,
2184
+ with `@types/three` in `devDependencies`, all at the same exact version.
2185
+
2186
+ **Import from `three` itself, never `three/addons/...`.** The published build
2187
+ maps a dependency's bare name but not its subpaths, so `three/addons/...`
2188
+ (VRButton, OrbitControls, GLTFLoader) works under `npm run dev` and then fails
2189
+ the build with "no importmap entry". An Enter VR button is a few lines:
2190
+
2191
+ ```tsx
2192
+ import * as THREE from 'three';
2193
+
2194
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
2195
+ renderer.xr.enabled = true;
2196
+ renderer.xr.setReferenceSpaceType('local-floor'); // y = 0 is the room's floor
2197
+ container.appendChild(renderer.domElement);
2198
+
2199
+ // Offer VR only where it exists. On a phone or desktop this resolves false and
2200
+ // the same scene stays a normal 3D view.
2201
+ const vr = (await navigator.xr?.isSessionSupported('immersive-vr').catch(() => false)) ?? false;
2202
+
2203
+ // Call from the button's click handler, and request the session before any
2204
+ // other await: the click is what lets the browser start it.
2205
+ async function enterVr(xr: XRSystem) {
2206
+ const session = await xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor'] });
2207
+ await renderer.xr.setSession(session);
2208
+ }
2209
+
2210
+ // Not requestAnimationFrame: in VR the headset drives the frames.
2211
+ renderer.setAnimationLoop(() => renderer.render(scene, camera));
2212
+ ```
2213
+
2214
+ When you need an addon, copy its file from `node_modules/three/examples/jsm/`
2215
+ into `src/vendor/three/`, with the files it imports by relative path, keeping
2216
+ the folders. Its other imports are `three`, which the build maps. Beside it, add
2217
+ a one-line `.d.ts` so TypeScript uses `@types/three`'s types, for example
2218
+ `src/vendor/three/loaders/GLTFLoader.d.ts` holding
2219
+ `export * from 'three/examples/jsm/loaders/GLTFLoader.js';`.
2220
+
2221
+ **3D models.** Put each model in your content pack as a `.glb`
2222
+ (`content/models/chair.glb`) and read its bytes with `useFiasContent()` (see
2223
+ that section; it needs the `entities:client_invoke` permission). Your plugin
2224
+ cannot fetch, so `GLTFLoader.load(url)` fails, but `parseAsync` takes the
2225
+ bytes. Copy GLTFLoader in as above (`loaders/GLTFLoader.js` plus
2226
+ `utils/BufferGeometryUtils.js` and `utils/SkeletonUtils.js`) and swap its
2227
+ texture loader: three.js decodes embedded textures with `fetch()`, which the
2228
+ sandbox blocks, and the model then loads with NO textures and only a console
2229
+ warning says why.
2230
+
2231
+ ```tsx
2232
+ import * as THREE from 'three';
2233
+ import { useFiasContent } from '@fias/arche-sdk';
2234
+ import { GLTFLoader } from './vendor/three/loaders/GLTFLoader.js';
2235
+
2236
+ const content = useFiasContent();
2237
+ const loader = new GLTFLoader();
2238
+ loader.register((parser) => {
2239
+ parser.textureLoader = new THREE.TextureLoader(parser.options.manager); // loads through <img>
2240
+ return { name: 'fias_no_fetch_textures' };
2241
+ });
2242
+ const gltf = await loader.parseAsync(await content.getBytes('models/chair.glb'), '');
2243
+ scene.add(gltf.scene);
2244
+ ```
2245
+
2246
+ What a model may hold, and why:
2247
+
2248
+ - One self-contained binary glTF 2.0 file, at most 25 MB: everything packed
2249
+ inside, with no separate `.bin` or image files. A `.gltf` with side files is
2250
+ refused; Blender's glTF exporter makes the right kind with "glTF Binary (.glb)".
2251
+ - At most 64 textures, each a still PNG, JPEG, WebP or AVIF. Every texture is
2252
+ screened like an image file and counts toward the pack's 2,000 images; the
2253
+ names and `extras` in the model are reviewed like text.
2254
+ - Export without compression. KTX2 / Basis textures are refused, and Draco or
2255
+ meshopt geometry cannot be decoded in a plugin: their three.js decoders need
2256
+ WebAssembly, which the plugin's sandbox does not run.
2257
+
2258
+ A texture you apply in code can be its own image file in the pack: pass the URL
2259
+ from `content.getObjectUrl('textures/wall.webp')` to
2260
+ `new THREE.TextureLoader().loadAsync(url)`.
2261
+
2262
+ Notes:
2263
+
2264
+ - Entering VR or AR needs a click, and the browser may ask the person for
2265
+ consent. `immersive-ar` (passthrough on Quest) starts the same way: create
2266
+ the renderer with `alpha: true` and hide your sky so the room shows through.
2267
+ - Head and controller positions reveal things about the person, like their
2268
+ height. Use them to draw the scene, not as data you save or send.
2269
+ - To test on a headset with `fias-dev`, the page must be a secure context.
2270
+ `localhost` is one and a LAN address (`http://192.168.…`) is not, so connect
2271
+ the headset by USB with developer mode on, run the commands below, and open
2272
+ `http://localhost:3200` in the headset's browser. A published arche needs
2273
+ nothing extra.
2274
+
2275
+ ```bash
2276
+ adb reverse tcp:3100 tcp:3100 # your plugin's dev server
2277
+ adb reverse tcp:3200 tcp:3200 # the fias-dev harness
2278
+ ```
2279
+
2174
2280
  ### `useStepNavigation()` — Multi-step workflows
2175
2281
 
2176
2282
  **Permission:** None for in-memory use; `storage:sandbox` when `persistKey` is set (it reads/writes `__state/<persistKey>` via the storage bridge).
@@ -2461,6 +2567,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
2461
2567
  - **`window.alert()` / `confirm()` / `prompt()` do nothing** (no `allow-modals`): they don't throw — `confirm()` silently returns `false`, so gated actions never run. Build confirmation into your UI (a two-step button or an inline dialog) instead
2462
2568
  - **No `fetch()` or `XMLHttpRequest`** — all network access is blocked
2463
2569
  - **You _can_ open external links in a new tab** (e.g. `window.open('https://…', '_blank')`) — see "Opening external links" above
2570
+ - **You _can_ build VR and AR apps (WebXR)** for headsets like the Meta Quest — see "VR and AR apps (WebXR)" above
2464
2571
  - **No access** to parent DOM, cookies, or localStorage
2465
2572
  - **No external scripts or stylesheets** — everything must be bundled
2466
2573
  - All platform communication goes through the bridge (SDK hooks)