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

Документация

Web

Создавайте веб-приложения на Native Shell с теми же компонентами, стилями и логикой приложения, что и на нативных платформах. NSH отображает интерфейс в виде HTML-элементов и CSS: текст, изображения, поля ввода, прокрутка, визуальные стили и взаимодействия используют DOM браузера и веб-API.

counter.tsx
counter.tsxLive
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, ui } from "./palette";

function Button(props: { label: string; accent?: boolean; onPress: () => void }) {
  return (
    <View onPress={props.onPress} {...button(props.accent ? "accent" : "secondary", { align: "center", style: { minWidth: 96 } })}>
      <Text style={buttonText(props.accent ? "accent" : "secondary")}>{props.label}</Text>
    </View>
  );
}

function App() {
  const count = signal(0);
  const history = signal<number[]>([]);

  const change = (delta: number) => {
    const next = count() + delta;
    count.set(next);
    history.update((h) => [next, ...h].slice(0, 5));
  };

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 24, paddingTop: 64, gap: 24 }}>
      <Text style={{ fontSize: 28, fontWeight: "bold", color: ui().text }}>nsh counter</Text>
      <View style={{ ...ui().card, borderRadius: 16, padding: 24, alignItems: "center", gap: 8 }}>
        <Text style={{ fontSize: 14, color: ui().muted }}>Current value</Text>
        <Text style={{ fontSize: 64, fontWeight: "bold", fontVariant: ["tabular-nums"], color: count() < 0 ? ui().down : ui().text }}>
          {count()}
        </Text>
      </View>
      <View style={{ flexDirection: "row", gap: 12, justifyContent: "center" }}>
        <Button label="−1" onPress={() => change(-1)} />
        <Button label="+1" accent onPress={() => change(1)} />
      </View>
      <View style={{ gap: 6 }}>
        <Text style={{ fontSize: 14, color: ui().muted }}>
          Recent values: <Text style={{ fontWeight: "bold", color: ui().text }}>{history().length}</Text>
        </Text>
        <Each of={history()}>{(value) => <Text style={{ fontSize: 16, color: ui().text }}>• {value()}</Text>}</Each>
      </View>
    </View>
  );
}

mount(App);

nsh package --target web публикует приложение как статический сайт для любого веб-сервера или CDN (Публикация приложения), а nsh run --target web запускает его в браузере, пока вы его редактируете (Разработка в браузере). Все живые демо на этом сайте используют этот веб-бэкенд.

Опубликованные TSX-приложения используют JavaScript-движок браузера. При упаковке каждому приложению подключается собственная среда выполнения, содержащая только те компоненты nsh/base и стандартные модули, которые приложение импортирует: приложение, собранное из базовых компонентов, загружает около 1,1 МБ. Данные для подсветки синтаксиса загружаются при первой подсветке кода.

#Что где выполняется

Страница строится из HTML-элементов, стилизованных с помощью CSS. NSH превращает обновления компонентов в пакеты изменений DOM, а браузер отображает интерфейс и обрабатывает прокрутку, выделение текста и ввод. WebAssembly содержит общий движок на Rust для состояния компонентов, раскладки и обновлений: либо с подключённым крейтом Rust-приложения, либо рядом с JavaScript-движком браузера, который выполняет код TSX-приложения как ES-модули: так работают опубликованные приложения, страница dev-сервера, демо на этом сайте и playground.

На каждой нативной платформе у движка есть собственный поток, и он передаёт главному потоку один MountBatch на каждый коммит. У страницы браузера один поток, поэтому веб-сборка управляет тем же движком пошагово из цикла событий страницы (nsh_engine::LocalEngine):

pointer / keyboard / scroll events ─┐
setTimeout (the engine's deadline) ─┼─▶ nsh.wasm: engine turn (Rust or JS, core, layout) ─▶ one JSON batch ─▶ nsh.js: DOM
requestAnimationFrame ──────────────┘                                                 ▲
                                                          native animation outputs ───┘
  • Раскладку выполняет ядро. Каждое представление — абсолютно позиционированный элемент с рамкой, вычисленной Taffy, поэтому flexbox, grid, aspectRatio и проценты ведут себя точно так же, как на устройствах. Собственная раскладка браузера не участвует.
  • Стили превращаются в CSS в Rust. Фон, границы, радиусы, тени (boxShadow, textShadow), градиенты, фильтры, backdropFilter, трансформации, transformOrigin, outline, mixBlendMode, переходы и встроенные keyframe-анимации становятся свойствами CSS. Классы Tailwind, состояния active:, hover: и focus:, а также dark: полностью разрешаются нативно, как и на устройствах.
  • Текст раскладывается в Rust с использованием измерений шрифтов из браузера (CanvasRenderingContext2D.measureText) и рисуется фрагмент за фрагментом в позициях, выбранных раскладкой, поэтому нарисовано именно то, что измерено. Строки переносятся после пробелов, вокруг CJK-символов и в \n; numberOfLines обрезает текст многоточием.
  • Нативные анимации выполняются в Rust на requestAnimationFrame: тайминг Animated, spring и decay, interpolate, анимированные значения, привязанные к opacity, translate, rotate, scale, шаги transform и backgroundColor, перетаскивания, привязанные через pan, значения, связанные с прокруткой, и аргументы canvas. JS не участвует в каждом кадре, как и на устройствах. LayoutAnimation, переходы стилей, keyframe-анимации animate-* и переходы между экранами работают так же, как на десктопных бэкендах, а prefers-reduced-motion отключает их (экраны плавно появляются).
  • Ввод: нажатия с обратной связью activeStyle / active: и долгие нажатия, система респондеров (onTouch*, PanResponder), нативные перетаскивания и щипок и поворот двумя пальцами, прокручиваемые представления (прокрутка браузера), pull to refresh, масштабируемые прокручиваемые представления (щипок, ctrl+колесо и щипки на тачпаде, zoomTo), <Input> с управляемыми значениями, фокус и отправка, Canvas, NavStack и NavPage со страничными header, Dialog.
  • Системный UI рисуется внутри страницы: AlertDialog выглядит как диалоги десктопных бэкендов в десктопных браузерах и как диалоги iOS на телефонах (с идиомой desktop, см. ниже). Пользовательские шрифты измеряются заново после их загрузки браузером, а приложение ожидает шрифты, которые оно импортирует, прежде чем показать первый кадр.
  • Диалоги — это слой поверх всего приложения, показывающий всплывающее окно и подложку приложения, как на десктопных бэкендах. Escape, кнопка «Назад» браузера и нажатие на подложку закрывают их (closeOnEscape, closeOnBackdropPress), а animation, onOpen, onClose, onCancel и onOpenChange работают как на устройствах. Страница, которая встраивает приложение в часть своей раскладки, может вместо этого показывать диалоги и оповещения поверх всего окна браузера (dialogs: "viewport"), как iOS и Android показывают их поверх всего экрана.

#Клавиатура, мышь и касания

Опубликованные приложения ведут себя как десктопные в десктопных браузерах и как мобильные в браузерах телефонов:

  • Колёса и тачпады прокручивают самое вложенное прокручиваемое представление по своей оси, которое ещё может двигаться (вертикальное колесо прокручивает горизонтальный список, если по вертикали ничего не прокручивается), и остаются на нём до конца прокрутки; щелчки колеса прокручивают плавно, а постраничные и привязывающиеся представления перемещаются на одну страницу за прокрутку. Ctrl + колесо и щипки на тачпаде управляют представлениями через нативные значения щипка вместо масштабирования страницы. Перетаскивание мышью не прокручивает, как и на десктопных платформах: перетаскивания мышью уходят в pan-жесты, систему респондеров и выделение текста. Прокручиваемые представления сообщают onScrollDragStart / onScrollDragEnd, onMomentumStart / End и onScrollEnd как на десктопных платформах.
  • Клавиатура: Tab и Shift+Tab перемещают между нажимаемыми элементами и полями ввода (стили focus: приложения показывают клавиатурный фокус); Return нажимает элемент в фокусе, Space нажимает его при отпускании; стрелки, Page Up / Page Down, Space, Home и End прокручивают прокручиваемое представление вокруг фокуса или под мышью; Escape выходит из поля, закрывает диалог, затем возвращает назад. Сочетания клавиш keymap работают в браузере и сопоставляются на месте (mod — это Command в Safari и Chrome на Mac, Control в остальных случаях).
  • Возврат назад: Escape, Alt+Left (Cmd+[ на Mac) и кнопка «Назад» мыши закрывают самый новый диалог или снимают самый вложенный стек, как кнопка «Назад» в Android; popOnBack и onBackPress работают так же. Когда приложение занимает всю страницу, каждая добавленная страница и каждый открытый диалог становятся записью в истории браузера, поэтому кнопка «Назад» браузера возвращает назад внутри приложения.
  • Касания: панорамируются только прокручиваемые представления; сама страница никогда не прокручивается, не масштабируется и не обновляется жестом под приложением, а у нажатий нет задержки. В iframe свайпы, которые приложение не обрабатывает, прокручивают встраивающую страницу. Перетаскивания на представлениях с нативными pan-жестами, щипками и системой респондеров принадлежат приложению.
  • Доступность: role, accessibilityLabel, accessibilityHint, accessibilityState, accessibilityValue, accessibilityLiveRegion и accessibilityElementsHidden становятся ARIA; announce озвучивается через live region, а setAccessibilityFocus перемещает фокус. Представления под диалогом или оповещением становятся инертными.
  • Выделение и меню: в десктопных браузерах текст по умолчанию можно выделять, как на веб-странице, кроме интерактивных элементов (представления с обработчиками нажатия или касания: кнопки, нажимаемые строки, вкладки) и текста с userSelect: "none" или select-none; перетаскивание мыши по нему выделяет его, Ctrl/Cmd+C копирует его построчно, а Ctrl/Cmd+A выделяет весь текст, в котором находится выделение. В браузерах телефонов и планшетов можно выделять только текст с userSelect: "text", как в нативных приложениях. Контекстное меню браузера появляется только в выделяемом тексте и полях ввода.

#Приложения с компонентами на Rust

Приложение, написанное на Rust (Сборка с Rust), публикуется и запускается в браузере из своего каталога:

nsh package --target web           # .nsh/out/<Name>/: the app's own nsh.wasm, with its crate
nsh run --target web               # the dev server's page; editing the Rust code builds it again

Когда nsh.json указывает корневой компонент приложения ("root"), страница состоит из Rust-кода приложения и скрипта страницы NSH, и больше ничего: nsh.wasm содержит движок, компоненты и крейт приложения, у сайта нет app.js, а страница монтирует корневой компонент, как только загружены среда выполнения и шрифты. Вся галерея на Rust занимает 3,3 МБ (1,06 МБ с gzip, 0,75 МБ с brotli), что меньше среды выполнения TSX-приложений. Под своей TSX-точкой входа (nsh package App.tsx --target web) то же приложение работает как TSX-приложения, на JavaScript-движке браузера, и импортирует компоненты и модули крейта.

В обоих случаях ресурсы крейта (assets!()) публикуются вместе с ресурсами приложения, а его шрифты загружаются до первого кадра. Во время nsh run --target web изменение Rust-кода крейта заново собирает среду выполнения, и открытая страница загружает её сама; правки TSX остаются горячими обновлениями. Среда выполнения для разработки собирается инкрементально, поэтому изменение крейта готово примерно за секунду; nsh package собирает публикуемую версию, оптимизированную по размеру.

Rust-код, работающий на всех платформах, включая веб, использует сервисы движка: fetch(url).await выполняет HTTP-запросы через тот же сетевой стек, что и fetch в JS (здесь это стек браузера с его правилами CORS); timer, set_timeout и set_interval ожидают; а nsh_core::time::Instant и nsh_core::time::unix_ms() читают часы. background и blocking выполняют работу в потоках нативных целей.

Для сборки нужны цель Rust и clang, компилирующий в WebAssembly, как и для сборки самой среды выполнения: rustup target add wasm32-unknown-unknown, а также clang и llvm-ar (на macOS — набор инструментов LLVM, например llvm из Homebrew, через CC_wasm32_unknown_unknown и AR_wasm32_unknown_unknown).

#API браузера из Rust

Крейт может вызывать собственные API браузера через wasm-bindgen и его крейты web-sys и js-sys за #[cfg(target_arch = "wasm32")]: WebSocket, IndexedDB, буфер обмена, любой Web API, а также собственный JS крейта (#[wasm_bindgen(inline_js = …)], module = "/js/…"). Когда веб-среда выполнения приложения вызывает JS таким образом (включая преобразования JsValue), nsh запускает на ней CLI wasm-bindgen и размещает его glue-код и JS крейта рядом со средой выполнения, как при разработке, так и в опубликованных сайтах. Версия CLI совпадает с версией крейта wasm-bindgen, который подключает среда выполнения и который указывает nsh: cargo install wasm-bindgen-cli --version <version> --locked устанавливает его (NSH_WASM_BINDGEN указывает CLI в другом месте, а не на PATH). Среда выполнения, которая подключает wasm-bindgen, но не вызывает через него JS (через возможности зависимости по умолчанию), собирается так же, как любая другая.

Крейт указывает используемые API как возможности web-sys, а закрепление wasm-bindgen сохраняет его версию, а значит и версию CLI, одинаковой от сборки к сборке:

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "=0.2.129"
web-sys = { version = "0.3", features = ["WebSocket", "MessageEvent"] }
futures-channel = "0.3"

Обратные вызовы браузера (пришедшее сообщение, таймер страницы) пробуждают задачи приложения, которые выполняются сразу:

#[cfg(target_arch = "wasm32")]
async fn echo(url: &str, text: String) -> Result<String, String> {
    use wasm_bindgen::{JsCast, prelude::*};
    let ws = web_sys::WebSocket::new(url).map_err(|e| format!("{e:?}"))?;
    let (tx, rx) = futures_channel::oneshot::channel();
    let tx = std::cell::RefCell::new(Some(tx));
    let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
        if let (Some(tx), Some(data)) = (tx.borrow_mut().take(), e.data().as_string()) {
            let _ = tx.send(data);
        }
    });
    ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
    on_message.forget();
    let open = ws.clone();
    let on_open = Closure::<dyn FnMut()>::new(move || {
        let _ = open.send_with_str(&text);
    });
    ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
    on_open.forget();
    rx.await.map_err(|_| "closed".to_owned())
}

Вызовы API браузера из крейта выполняются с собственными разрешениями страницы, так же как нативный код крейта выполняется с разрешениями приложения на других платформах: возможности nsh.json управляют тем, что проходит через движок (fetch, JS приложения), а правила браузера (CORS, Content-Security-Policy страницы) управляют каждым запросом, который выполняет страница.

#Публикация приложения

# A Rust app, from its directory
nsh package --target web                   # .nsh/out/<Name>/: a static site
nsh package --target web --run             # and serve it on this machine (open the address it prints)

# A TSX app
nsh package App.tsx --target web
nsh package App.tsx --target web --run

nsh package --target web записывает .nsh/out/<Name>/ (--out для другого каталога) со структурой, как у сборки фронтенда: одна страница и файлы, названные по их содержимому.

ФайлЧто это
index.htmlСтраница приложения: его название в качестве заголовка, значки, цвета его appearance, индикатор загрузки и сообщение, если приложение не удалось загрузить или запустить
manifest.webmanifestНазвание, id, значки и цвета, с которыми браузеры устанавливают приложение
nsh.<digest>.jsСреда выполнения DOM в минифицированном виде (79 КБ, 22,5 КБ с brotli)
nsh.<digest>.wasmДвижок на Rust, раскладка и компоненты, а также собственный крейт Rust-приложения. TSX выполняется в JavaScript браузера; при упаковке подключаются только те компоненты и стандартные модули, которые импортирует первый экран приложения.
nsh-<name>.<digest>.wasmКомпоненты, стандартные модули и шейдеры, которые приложение использует за пределами первого экрана: загружаются в фоне после его показа
<file>.br, <file>.gzКаждый файл, сжатый заранее, для хостов, отправляющих предварительно сжатые файлы
nsh-syntaxes.<digest>.binСинтаксисы, по которым подсвечивается код (набор bat: 0,92 МБ, 0,8 МБ с brotli), загружаются при первом показе кода на каком-либо языке в блоке кода, Editor или DiffView
nsh-bindgen.<digest>.jsМинифицированный glue-код wasm-bindgen для приложения, крейты которого вызывают API браузера из Rust
snippets.<digest>/<crate>-<hash>/…JS этих крейтов, который импортирует glue-код (inline_js, module = "/js/…"), по путям, которые задаёт wasm-bindgen
app.<digest>.jsСкомпилированная TSX-точка входа в виде ES-модуля, загружаемого браузером.
host.<digest>.jsХост страницы, nsh.json web.host (функции хоста)
<name>.<digest>.jsES-модули, загружаемые по требованию через import().
assets/<path>.<digest>.<ext>Каждый ресурс, который импортирует приложение, загружается по своему URL при использовании (шрифты — до выполнения кода, который их регистрирует, так как текст измеряется с их помощью: до запуска приложения или вместе с чанком, который их импортирует)
icons/<size>.<digest>.pngЗначок nsh.json размерами 32 (favicon), 180 (apple-touch-icon), 192 и 512 пикселей

<digest> — это начало SHA-256 содержимого файла: имя файла меняется ровно тогда, когда меняется его содержимое, поэтому новый релиз меняет только index.html и действительно изменившиеся файлы. Везде используются относительные URL, поэтому сайт работает из любого каталога на любом хосте (https://example.com/apps/counter/).

#Размер рантайма

При упаковке каждое приложение получает собственный nsh.<digest>.wasm с движком и компонентами nsh/base, стандартными модулями и шейдерами, которые импортирует первый экран приложения. То, что импортируют остальные экраны, загружается в фоне после показа первого экрана. Приложение, чьи крейты вызывают API браузера из Rust, получает в своём nsh.<digest>.wasm весь рантайм, включая эти крейты. Измерено с помощью nsh package --target web (МБ = 10^6 байт):

ПриложениеИспользуетnsh.<digest>.wasmgzipBrotli
examples/counterОсновные компоненты3,96 МБ1,32 МБ0,97 МБ
examples/galleryВсе компоненты, zlib и шейдеры4,13 МБ1,36 МБ1,01 МБ

#Развёртывание

Загрузите каталог на любой статический хост: объектное хранилище за CDN, Cloudflare Pages, Netlify, GitHub Pages, nginx. Два правила обеспечивают быструю загрузку и надёжное обновление:

ФайлыCache-Control
index.html, manifest.webmanifestno-cache (перепроверяются при каждой загрузке, поэтому релиз появляется сразу)
всё остальноеpublic, max-age=31536000, immutable

Отдавайте .wasm как application/wasm (тогда браузеры компилируют его во время загрузки), а браузерам, которые их поддерживают, отправляйте предварительно сжатые копии: brotli_static on; gzip_static on; в nginx, file_server { precompressed br gzip } в Caddy или CDN со сжатием Brotli. С Brotli рантайм примерно на четверть меньше, чем с gzip. nsh package --target web --run раздаёт сайт именно так на http://127.0.0.1:4173/.

Приложение, опубликованное для веба, работает как упакованное нативное: как релиз, с возможностями, которые объявляет его nsh.json (Песочница и возможности); TSX-приложение выполняется на JavaScript-движке браузера. Если они ограничивают network, страница также содержит connect-src Content-Security-Policy с объявленными хостами (и собственным origin страницы), поэтому сам браузер отклоняет запросы и переадресации на любой другой хост. Ошибка, из-за которой приложение не запускается, показывается на странице; последующие ошибки выводятся в консоль браузера.

#Встраивание через iframe

На вебе нет API для встраивания: встраивайте опубликованное приложение с помощью <iframe>. Его размер — это окно приложения:

<iframe src="https://apps.example.com/counter/" title="Counter"
        style="width: 390px; height: 844px; border: 0"></iframe>

#Разработка в браузере

nsh run --target web               # a Rust app, from its directory
nsh run App.tsx --target web       # a TSX app

запускает dev-сервер и выводит адрес его страницы, http://127.0.0.1:8790/: откройте его в любом браузере. Страница — такой же хост для разработки, как и остальные: она запускает каждую сборку приложения, показывает ошибки сборки и ошибки выполнения поверх приложения и отправляет логи приложения в терминал. Изменение крейта Rust-приложения заново собирает его рантайм, и открытая страница загружает его сама; правки TSX применяются на месте (горячие обновления). Телефоны в той же сети открывают адрес, который nsh run печатает под ним (on this network: http://192.168.…:8790/). nsh dev раздаёт ту же страницу, если веб-рантайм установлен (в дистрибутиве — всегда).

#Сборка плеера для сайта

cargo xtask web                       # nsh.wasm, nsh.js, the player and website/demos/*.tsx
cargo xtask web --out target/play     # somewhere else

Выходной каталог содержит:

ФайлЧто это
gallery/nsh.<digest>.wasmРантайм галереи, скомпонованный для неё nsh package (использует все компоненты и zlib: около 11,8 МБ, 3,6 МБ в gzip)
gallery/nsh-syntaxes.<digest>.binСинтаксисы, по которым рантайм подсвечивает код; загружаются, когда демо впервые показывает код на соответствующем языке
nsh-playground.wasmТо же плюс TSX-компилятор NSH, для Playground (около 8 МБ, 2,4 МБ в gzip)
gallery/nsh.<digest>.jsDOM-рантайм (минифицированный): загружает wasm, применяет пакеты изменений, превращает ввод в события
index.htmlСтраница плеера: запускает demos/<name>.js для #<name>
nsh.wasm, nsh.js, browser/Рантайм, который загружает плеер: движок, DOM-рантайм и модули хоста браузера
demos/<name>.jsКаждое демо, скомпилированное nsh build --release: ES-модуль
gallery/Приложение-галерея, опубликованное с помощью nsh package --target web, показывается на странице Gallery

Раздайте каталог любым статическим сервером (для потоковой компиляции ему нужен MIME-тип application/wasm) и откройте index.html#counter.

#Playground

Playground позволяет редактировать и запускать приложение в браузере: страница компилирует TSX собственным JSX-компилятором NSH (встроен в nsh-playground.wasm, без rolldown) и запускает результат на JavaScript-движке браузера после каждой паузы в наборе. У каждого демо на этом сайте есть ссылка Edit in Playground, а Share помещает код в саму ссылку, поэтому на сервере ничего не хранится.

Приложение в Playground — это один модуль: он импортирует модули NSH (nsh и nsh/*), но не другие файлы или пакеты. Вывод console.log и ошибки появляются под телефоном.

Компилятор также доступен как Rust-функция nsh_bundler::compile_module(name, source, ui_sets) — без фичи bundle крейта.

#Встраивание в страницу

Rust-приложение встраивается как опубликованный сайт, в iframe. Для TSX nsh.js — это ES-модуль. createPlayer загружает рантайм в контейнер; runModule(url) запускает бандл, который записывает nsh build (bundle.js, ES-модуль), на JavaScript-движке браузера, в собственном realm приложения, заменяя то, что работало. Метод возвращает промис, который разрешается после загрузки модуля и отклоняется с ошибкой, остановившей его; эту ошибку сообщает и плеер (onError):

import { createPlayer } from "./nsh.js";

const player = await createPlayer(document.querySelector("#phone"), {
  wasm: "./nsh.wasm",
  assetBase: "./assets/",   // where `asset:/…` URIs (image imports) are served from
  // dark: true,            // force an appearance; otherwise it follows the system
  // idiom: "phone",        // force "phone", "pad" or "desktop"; otherwise the pointer and the size decide
  // history: false,        // pages and dialogs as browser history entries; by default only when the app is the page, not in an iframe
  // dialogs: "viewport",   // Dialog, Sheet and AlertDialog over the whole browser window; by default over the container
});
await player.runModule(new URL("./bundle.js", location.href).href);

assets (отображение путей asset:/ на URL) заменяет assetBase, readAsset(uri) возвращает байты ресурса, который нужен движку сразу (шрифта) или null, data сопоставляет файлы, которые рантайм загружает при первой необходимости, с их URL (syntaxes: nsh-syntaxes.<digest>.bin; по умолчанию nsh-<name>.bin рядом с nsh.js), а glue — это URL wasm-bindgen-обвязки рантайма, чьи крейты вызывают API браузера из Rust (nsh-bindgen.<digest>.js), которую плеер загружает вместе с рантаймом; player.runModule(url, { capabilities, appId }) запускает приложение с объявленными им возможностями (его capabilities.json) и хранит данные под его идентификатором nsh.json (без него — по пути страницы). Страница, которую записывает nsh package --target web, использует всё это.

runModule(url, { chunks }) также принимает расположение чанков разделённого бандла.

player.runSource(code, { name, sourceMap, capabilities, appId }) запускает ES-модуль, заданный исходным текстом, с теми же параметрами. С nsh-playground.wasm player.compileAndRun(tsx, name, options) сначала компилирует TSX, запускает его с параметрами runSource и разрешается ошибкой компиляции или null; player.compile(tsx) возвращает { code } или { error }.

Размер контейнера — это окно приложения (windowSize()); изменение размера контейнера меняет размер приложения. player.setDark(true) переключает оформление во время выполнения, а приложения наблюдают это через классы colorScheme() и dark:.

Диалоги и алерты перекрывают контейнер. С dialogs: "viewport" они перекрывают всё окно браузера, как собственные модальные диалоги страницы, при этом окном приложения остаётся контейнер: Dialog раскладывается по размеру окна и безопасной области и выезжает от его нижнего края, его подложка закрывает всю страницу, а колесо мыши над ним не прокручивает страницу. Диалог показывается в верхнем слое браузера (Chrome 114, Safari 17, Firefox 125 и новее), поэтому он находится поверх остальной страницы, где бы ни был контейнер; в более ранних браузерах он перекрывает контейнер.

Sheet следует той же настройке показа, что и Dialog и AlertDialog. В iframe того же origin показ во viewport автоматически перекрывает внешнюю страницу, а приложение сохраняет исходный размер раскладки. Когда закрывается последнее всплывающее окно, iframe возвращается в исходное положение и размер; его движок и состояние остаются живыми. Демо в документации включают такой показ.

Для iframe с другим origin или в песочнице (sandbox) встраивающая страница регистрирует хост до загрузки плеера:

import { hostPlayerFrame } from "./nsh.js";
const frame = document.querySelector("iframe");
const disconnect = hostPlayerFrame(frame);
frame.src = "https://apps.example.com/player.html"; // player uses dialogs: "viewport"
// When removing the embedding: disconnect();

Пока всплывающее окно видно, хост принимает запросы от окна этого iframe, делает окружающую страницу неактивной (inert) и блокирует её прокрутку, а при закрытии восстанавливает и то и другое. Каждая граница встраивания между разными origin требует содействия своего хоста; без него показ остаётся в пределах этого документа, а плеер сообщает предупреждение.

#Функции хоста

В вебе страница — это хост приложения: она отвечает на host.call(name, args) host и отправляет события, которые слушает host.on. host у createPlayer — объект, чей call(name, args) возвращает результат или промис с ним; выброшенное исключение или отклонение отклоняет вызов приложения с сообщением об ошибке. player.hostEvent(event, payload) отправляет событие с JSON-данными (если данные не являются JSON, выбрасывается исключение). При host host.available() равно true.

const player = await createPlayer(container, {
  wasm: "./nsh.wasm",
  host: {
    async call(name, args) {
      if (name === "user.info") return { name: "Ada" };
      throw new Error(`no host function ${name}`);
    },
  },
});
player.hostEvent("theme", { dark: true });

Опубликованное приложение получает свой хост из nsh.json: web.host указывает JS-модуль в каталоге приложения, относительно nsh.json, который экспортирует createHost(player). nsh package --target web копирует его в сайт как host.<digest>.js, а страница запускает createHost вместе с плеером до старта приложения; то, что он возвращает (или чем завершается), должно иметь функцию call, иначе страница сообщает, что приложение не удалось загрузить. createHost, который получает плеер, с этого момента отправляет события:

{
  "capabilities": {
    "host": ["user.*"]
  },
  "web": { "host": "./host.js", "connect": ["api.example.com"] }
}
// host.js
const post = (url) => (args) =>
  fetch(url, { method: "POST", body: JSON.stringify(args) }).then((r) => r.json());

// The functions the app may call, by their exact names.
const functions = new Map([
  ["user.info", post("https://api.example.com/user/info")],
  ["user.settings", post("https://api.example.com/user/settings")],
]);

export async function createHost(player) {
  addEventListener("online", () => player.hostEvent("online", {}));
  return {
    call(name, args) {
      const f = functions.get(name);
      if (!f) throw new Error(`no host function ${name}`);
      return f(args);
    },
  };
}

Хост ищет каждое имя как есть в таблице своих функций, как показано выше, а свои URL и пути определяет сам: capabilities.host разрешает имена по их началу (user.*), поэтому имя, которое хост превратил в путь, могло бы дотянуться до того, что разрешение не охватывает. Рантайм пропускает имена длиной от 1 до 128 символов из A–Z, a–z, 0–9, _, ., : и -, без .., а вызов любого другого имени отклоняет. Разрешение по префиксу заканчивается разделителем (bridge.*): bridge* тоже совпадает с bridgeAdmin.

События, отправленные до запуска приложения, например самим createHost, доставляются по порядку, как только оно стартует, так что слушатели host.on верхнего уровня приложения их получают. Событие, отправленное до регистрации слушателя, ему повторно не доставляется: приложение читает начальное состояние через вызов (host.call("user.info")), а его изменения отслеживает через события.

Модуль хоста выполняется на странице приложения и разделяет её Content-Security-Policy: когда возможности приложения ограничивают network, connect-src страницы содержит origin страницы, хосты, перечисленные в capabilities.network, и хосты, перечисленные в web.connect. web.connect называет хосты, до которых доходят собственные запросы модуля хоста (fetch, XMLHttpRequest, WebSocket), в виде шаблонов, как у capabilities.network.hosts (api.example.com, *.example.com, "*"), как api.example.com выше; собственные запросы приложения по-прежнему проверяются по его capabilities.

Модуль копируется как есть, поэтому другой код он импортирует по абсолютному URL. Для каждой вызываемой функции приложению по-прежнему нужна его возможность host. nsh run --target web запускает его на странице разработки, а nsh test запускает тот же модуль для каждого приложения, которое открывают его тесты, в headless-режиме или в браузере (--target web), так что приложение и его хост тестируются вместе (Тестирование).

#Браузер как платформа

platform.os — это "web". Тип устройства, на котором работает браузер, определяет поведение приложения, как platform.idiom:

  • "desktop": мышь (точный указатель с наведением) и контейнер шириной не менее 600 пунктов. platform.desktop равно true, platform.select выбирает desktop, и применяются свойства desktop={{…}}.
  • "pad": сенсорный экран, короткая сторона которого не менее 600 пунктов; иначе "phone". Это мобильные устройства: platform.select выбирает mobile.

Идиома интерфейса определяется при запуске приложения и не меняется, пока оно работает, как и на любой платформе; узкое окно на десктопе остаётся десктопом, с раскладкой под его windowSize(). Свойства web={{…}}, классы platform.select({ web }) и web: применяются только в браузерах; ios={{…}}, native и ios: там не применяются.

Страница сообщает остальные сведения об окружении и поддерживает их актуальными:

ЗначениеИз браузера
Локаль (по умолчанию для Intl)navigator.languages[0]
Часовой пояс (локальное время Date, значение timeZone по умолчанию для Intl)пояс браузера (Intl.DateTimeFormat().resolvedOptions().timeZone), включая летнее время
Направление раскладки (rtl: / ltr:)<html dir>, если задан, иначе письменность локали
reduceMotion()prefers-reduced-motion
fontScale()размер шрифта корневого элемента / 16 px (настройка размера текста в браузере)
pixelRatio()devicePixelRatio (меняется при масштабировании и смене экранов)
colorScheme()prefers-color-scheme
safeArea()env(safe-area-inset-*) там, где они перекрывают контейнер (страницы viewport-fit=cover на телефонах)
keyboardFrame()какую часть контейнера закрывает экранная клавиатура (visualViewport)

platform.version равно "0.0", а screenReaderEnabled() остаётся false (браузеры этого не сообщают). Панели браузера вокруг страницы принадлежат браузеру; statusBar страницы нужен для телефонов.

#Сеть

fetch (со стримами, AbortController и FormData) и URL <Image> проходят через собственный сетевой стек браузера после того, как NSH проверит разрешения (capabilities) приложения. Поскольку запросы выполняет браузер, действуют его правила:

  • Относительные URL разрешаются относительно URL страницы, как в браузере: опубликованное приложение обращается к своему API через fetch("/api/items"), а <Image src="/photos/a.jpg" /> загружается с собственного сервера. (На нативных платформах страницы нет, поэтому там нужны абсолютные URL.)
  • CORS. Запрос к источнику, отличному от источника страницы, успешен, только если сервер разрешает источник страницы (Access-Control-Allow-Origin); иначе fetch завершается сетевой ошибкой. Видны только те заголовки ответа, которые сервер явно раскрывает.
  • Cookies (и HTTP-аутентификация) отправляются только на собственный источник страницы (credentials: "same-origin"), а заголовки, запрещённые браузерами (Cookie, User-Agent, Host, …), отбрасываются.
  • Редиректы выполняются браузером. Запрос, который не должен следовать редиректу, завершается ошибкой: redirect: "manual" и "error", а также запросы приложения, чьи capabilities ограничивают network (NSH проверяет каждый переход, а браузеры не сообщают о редиректе до того, как последуют ему).
  • Тела запросов, являющиеся ReadableStream, сначала собираются целиком и отправляются одним куском; тела ответов передаются потоком по мере поступления.

#Разделённые бандлы

TSX-приложению для веба не нужно загружать весь свой код до показа первого экрана (приложение на Rust — это один nsh.wasm, который браузер компилирует по ходу загрузки). nsh package --target web публикует TSX-приложения разделёнными: каждый чанк лежит в виде <name>.<digest>.js, а страница сопоставляет чанки с этими файлами. Чтобы разделить бандл самостоятельно, nsh build --split помещает модули, которые загружает import(), в отдельные чанки рядом с bundle.js:

nsh build --release --split -o site/app
# bundle.js        the app up to its first screen
# Screen.js        import("./Screen") and what only it uses
# detail.js        what two chunks share, when they do (made by the bundler)

Первый import("./Screen") загружает Screen.js и все чанки, которые тот импортирует, одновременно, затем выполняет их; последующие импорты разрешаются сразу. Страница сообщает, где находятся чанки: по умолчанию рядом с бандлом, либо по одному URL на чанк — для файлов, переименованных с добавлением хеша содержимого, чтобы CDN мог кешировать их бессрочно:

await player.runModule(entry, {
  chunks: { "Screen.js": "/app.3f2a9c.js", "detail.js": "/app.81bb07.js" },
});

Чанк, который не удалось загрузить, отклоняет import() с TypeError, указывающей на него; следующий import() попробует снова. Имена чанков — это фиксированные имена файлов из бандла: приложение не может заставить страницу загрузить что-либо другое.

Галерея загружает каждое демо таким способом: lazy (в examples/gallery/ui.tsx) создаёт компонент, который запускает импорт при первом рендере, до его завершения показывает заглушку, затем отображает загруженное. Его первая загрузка — это индекс и демо, показываемое первым; шрифт, который импортирует демо, поставляется вместе с чанком этого демо. Нативные сборки содержат все модули, поэтому там импорт разрешается сразу:

const Stocks = lazy(() => import("./demos/Stocks").then((m) => m.Stocks));

#Ограничения

Эти ограничения определяет браузер, а веб-бэкенд им следует:

  • Веб-представления других сайтов: WebView — это <iframe>. Встроенный HTML (html) получает всё: onMessage, postMessage, injectJavaScript, внедряемые скрипты и события загрузки (он выполняется с собственным источником, отдельным от страницы). Страница другого сайта получает только postMessage и события загрузки с переданным ей URL: браузеры скрывают от окружающей страницы её переходы, заголовок, историю и ошибки, а userAgent задать нельзя.
  • Видео со звуком, запускающиеся автоматически: Video — это <video> со всеми свойствами и событиями, но браузеры запускают видео со звуком только после действия пользователя (видео без звука воспроизводятся).
  • Нативные компоненты: зарегистрированные нативные компоненты принадлежат нативным приложениям, встраивающим NSH; в вебе их представление остаётся пустым, а в консоль выводится предупреждение.
  • Платформенные сервисы: чтение буфера обмена (Clipboard.readText, а также hasText) требует безопасного контекста (HTTPS) и может запросить разрешение у пользователя. Изображения не сообщают onProgress.
  • Детали обработки текста: разбиение на строки реализует подмножество UAX #14 (пробелы, CJK, дефисы, а также правила начала и конца строки для CJK-пунктуации: закрывающие знаки, такие как 。, , и 」, не переносятся в начало строки, открывающие, такие как ( и 「, — в её конец), без переносов слов и без двунаправленного переупорядочивания сверх направления абзаца.

Плеер и dev-сервер запускают приложения с настройками разработки; опубликованные приложения работают как релизные. Оба выполняют код TSX-приложения на JavaScript-движке браузера, в собственном realm приложения.

#См. также

  • Packaging: другие цели nsh package.
  • Architecture: движок, ядро и батчи монтирования, которые переиспользует веб-сборка.
  • Testing: nsh test запускает тот же движок без интерфейса, с приблизительным текстовым движком.