Перейти к содержимому

WASM-острова

Эта страница переведена с английского. Читать страницу на английском

Zeyn отображает страницы на сервере. Некоторые поверхности слишком интерактивны для этого: редактор на canvas, диаграмма, тяжёлый график. Остров (island) это компонент на Rust, скомпилированный в WebAssembly, который сервер размещает на странице, в остальном отображаемой на сервере. Всё вокруг него остаётся обычным HTML.

Острова находятся за Cargo feature islands крейта zeyn. Приложения, не включающие её, ничего из этого не компилируют, а страницы без острова ничего не платят, даже когда она включена (см. «Производительность» ниже).

1. Составные части

ЧастьГдеРоль
zeyn::ui::IslandсерверУзел-заглушка: props, запасное содержимое, привязанные действия
App::islands(dir)серверОтдаёт собранные файлы и загрузчик
zeyn-islandобщий и браузерRemote (контракт вызываемого действия), Island::call (клиент)
Общий крейтобаProps, сообщения и remotes как простые типы serde
Крейт островабраузерcdylib, экспортирующий mount(root) и, по желанию, unmount(root)
zeyn build-islandsCLIСобирает крейты островов в файлы с хешами и манифест

Типичная структура, как в examples/showcase:

examples/showcase/
  shared/              showcase-shared: props, Count, SaveCount (serde + zeyn-island only)
  islands/counter/     the island crate, [lib] name = "counter", crate-type = ["cdylib", "rlib"]
  src/islands.rs       the screen and the action
  dist/islands/        output of zeyn build-islands (not committed)

2. Общие типы

Общий крейт зависит от serde и от zeyn-island без features, поэтому сервер не компилирует код для браузера. Remote называет действие, которое остров может вызвать, и фиксирует его типы:

#[derive(Serialize, Deserialize)]
pub struct Count { pub value: i64 }

pub struct SaveCount;

impl zeyn_island::Remote for SaveCount {
    const NAME: &'static str = "save_count";
    type Input = Count;
    type Output = Count;
}

3. Серверная сторона

#[action(permission = "showcase.islands.write")]
pub async fn save_count(count: Count, store: CounterStore) -> Result<Count> { ... }

Island::new("counter")
    .props(&CounterProps { start, texts })
    .label(t!("islands.counter"))
    .fallback(Alert::info(t!("islands.fallback")))
    .action::<SaveCount, _>(save_count)

action::<R, A> компилируется только тогда, когда вход действия равен R::Input, а его выход равен Result<R::Output>, поэтому две стороны не могут разойтись. Остров может вызывать только те действия, которые привязаны к его узлу.

fallback принимает любые узлы: текстовое резюме, сетку только для чтения. Он показывается до загрузки острова, при отключённом JavaScript и при сбое загрузки. Его сетки и другие компоненты работают как обычно.

Загрузка: .on_interaction() ждёт первого указателя, касания или фокуса на заглушке, .on_idle() загружает, когда браузер простаивает, а по умолчанию загрузка происходит, когда заглушка оказывается в пределах 200 px от области просмотра. Загрузка никогда не блокирует страницу.

Зарегистрируйте папку вывода один раз: App::islands(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/islands")). Без манифеста в ней сервер пишет в лог предупреждение, а острова показывают свой запасной вариант.

4. Сторона острова

#[wasm_bindgen]
pub fn mount(root: web_sys::Element) -> Result<(), JsValue> {
    let island = zeyn_island::Island::new(root.clone());
    let props: CounterProps = island.props().map_err(|e| JsValue::from_str(&e.message))?;
    // build DOM under root, attach listeners
    zeyn_island::spawn(async move {
        match island.call::<SaveCount>(&Count { value: 3 }).await {
            Ok(saved) => { /* ... */ }
            Err(e) => { /* e.message is localized by the server */ }
        }
    });
    Ok(())
}

Island::call отправляет POST /_zeyn/data с экраном острова, параметрами и путём узла, именем remote в качестве key и входными данными. Он отправляет CSRF-токен страницы в x-zeyn-csrf и cookie сессии. Сервер проверяет, как для каждого компонента: CSRF, доступ экрана, guards feature, permission действия, затем выполняет interceptors и действие с лимитом времени. Другого пути нет: остров не может добраться до действия, которое не привязано к его узлу, и ничто в островах не обходит проверку.

Ошибки возвращаются как CallError { status, code, message }. status равен 0 для локальных сбоев (network, props, input, answer).

Экспортируйте unmount(root), когда остров держит таймеры, кадры анимации или сокеты. Загрузчик вызывает его, когда заглушка покидает документ (навигация, закрытое окно).

Переводы: передавайте тексты, которые показывает остров, в его props, уже локализованными сервером, чтобы остров не поставлял каталог.

5. Сборка

cd examples/showcase
zeyn build-islands            # every crate in islands/, output in dist/islands
zeyn build-islands --out public/islands islands/counter

Для каждого крейта выполняется:

  1. cargo build --lib --release --target wasm32-unknown-unknown с настройками размера, переданными через --config (opt-level = "z", lto = true, codegen-units = 1, panic = "abort", без отладочной информации), поэтому собственный релизный профиль приложения не затрагивается;
  2. wasm-bindgen --target web --no-typescript --remove-name-section --remove-producers-section (версия wasm-bindgen-cli должна совпадать с крейтом wasm-bindgen, 0.2.130);
  3. wasm-opt -Oz, когда binaryen есть в PATH;
  4. запись {name}.{hash}.js и {name}_bg.{hash}.wasm (64-битный хеш FNV от содержимого) и обновление manifest.json.

zeyn dev делает то же самое перед каждой сборкой приложения, когда у приложения есть папка islands/, и не следит за dist/.

Сервер отдаёт только имена файлов, перечисленные в манифесте, по адресу /_zeyn/islands/{file}, с application/wasm или text/javascript и Cache-Control: public, max-age=31536000, immutable. Файлы читаются с диска при каждом запросе, поэтому не занимают память сервера.

6. Content Security Policy

Для компиляции WebAssembly нужен 'wasm-unsafe-eval' в script-src. Zeyn добавляет его только на страницы, экран которых содержит остров; каждая другая страница сохраняет строгую политику из docs/runtime-protocol.md. 'wasm-unsafe-eval' разрешает WebAssembly.compile и instantiate и ничего больше: eval, new Function и встроенные скрипты остаются заблокированными, а модули по-прежнему приходят только с origin приложения.

Политика принадлежит документу и не может быть смягчена позже. Поэтому, когда nav.js загружает экран с островом (частичный ответ несёт x-zeyn-islands: 1) в документ, загруженный без неё, происходит откат к полной загрузке страницы. Когда у документа уже есть политика островов, переходы между экранами с островами и без них остаются на месте.

Сервер находит острова по атрибуту data-zeyn-island="; экранированные данные пользователя никогда не могут породить эту кавычку, поэтому набранный пользователем текст не может включить политику.

7. Поверхности canvas

zeyn::ui::Canvas::new(name, description) это остров для рисования. Описание обязательно: canvas невидим для вспомогательных технологий, поэтому страница должна сказать, что он показывает и какими клавишами им управлять. Сервер отображает его абзацем над островом и указывает на него в aria-describedby заглушки.

Со стороны острова zeyn-island с feature canvas предоставляет canvas::Surface:

let surface = Surface::new(&stage, Editor::new(..), Prefer::TwoD, &texts.canvas, &described_by, 420)?;
surface.update(|editor| editor.add_box());   // change the scene, draw on the next frame
ЗаботаЧто делает поверхность
ГрафикаPrefer::Gl пробует WebGL 2 и откатывается к 2D; webgpu_available() сообщает, есть ли navigator.gpu, для сцен, которые сами настраивают WebGPU на surface.canvas()
Высокий DPIРезервный буфер следует за devicePixelRatio и размером элемента (ResizeObserver); 2D-рисование предварительно масштабируется, поэтому сцена работает в CSS-пикселях
ВводНажатие, перемещение и отпускание указателя (с захватом указателя), колесо (дельты строк и страниц переводятся в пиксели) и клавиши, как значения Input в CSS-пикселях
КадрыrequestAnimationFrame только после ввода, использованного сценой, после update, изменения размера или пока Scene::animating возвращает true. Неподвижная картинка не стоит процессорного времени
Значения по умолчаниюУ ввода, использованного сценой, отменяется действие по умолчанию (стрелки не прокручивают страницу); возвращайте false для Tab, чтобы фокус мог уйти
Очисткаdestroy() удаляет обработчики, наблюдатель и canvas; вызывайте его из unmount

Canvas получает tabindex="0", role="application", подпись и aria-describedby.

Шаблон доступности, как в редакторе диаграмм showcase (examples/showcase/islands/diagram):

  • Клавиатура на canvas: ] и [ выбирают фигуры, стрелки двигают их, Delete удаляет, +, - и 0 масштабируют, Escape снимает выделение. Клавиши действуют, только пока у canvas есть фокус.
  • Обычные элементы управления: панель кнопок делает то же без canvas.
  • Текстовое резюме: список под canvas описывает каждую фигуру и следует за каждым изменением; строка role="status" объявляет выделение и результаты сохранения.
  • Запасной вариант: сервер отображает то же резюме, поэтому пользователи без WebAssembly по-прежнему читают диаграмму.

Редактор сохраняет через SaveDiagram, а сервер ограничивает то, что получает (Diagram::clamped: не более 200 фигур, уникальные id, подписи по 40 символов, ограниченные координаты), потому что острову никогда не доверяют.

8. Производительность

ЧтоЗначение
HTML обычной страницы, запросы, политикабез изменений: нет meta, нет загрузчика, нет дополнительного запроса
nav.js+281 байт (откат к полной загрузке)
Загрузчик /_zeyn/islands.js, только страницы с островами3.9 КБ, 1.7 КБ gzip
.wasm острова-счётчика (без wasm-opt)127.6 КБ, 52.8 КБ gzip
Связующий .js острова-счётчика18.0 КБ, 4.2 КБ gzip
.wasm редактора диаграмм (поверхность canvas, без wasm-opt)169.4 КБ, 67.8 КБ gzip
Редактор диаграмм с живым редактированием (добавляет yrs, см. docs/realtime.md)408.4 КБ, 144.3 КБ gzip
Связующий .js редактора диаграмм30.3 КБ, 6.1 КБ gzip
Память сервера на островнет; файлы передаются потоком с диска, props формируются при каждом запросе

Большая часть размера счётчика приходится на serde_json и код форматирования из core. Острова, которые их разделяют, платят за них по разу каждый; wasm-opt обычно снимает ещё 10 до 20 процентов.

9. Ограничения

  • Остров внутри окна (open_window) требует, чтобы страница, открывающая окно, содержала политику островов; размещайте окна с островами на страницах с островами или открывайте их как экраны.
  • Замыкания, подключённые через Closure::forget, живут столько же, сколько страница; освобождайте их в unmount, когда остров может монтироваться много раз.
  • Сами острова на сервере ничего не отображают; запасной вариант это то, что получают поисковые системы, программы чтения с экрана до загрузки и пользователи без JavaScript. Сделайте его содержательным.