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

Голова без экрана

Книга открывается, правится и читается из Node — без единого обращения к DOM.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.

Страница, которую открывает браузер.

index.html
<!doctype html>
<!--
Голова без экрана — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ, и единственный, у которого
экрана нет вовсе.
Пример живёт в Node (`src/headless.ts`), а эта страница показывает то
единственное, что она вправе показать честно: КОД, который выполняется, и
команду, которой он запускается. Выдуманного «вывода» здесь нет намеренно:
записанный вчера вывод выглядит как работающий пример и им не является — это
ровно та ложная зелень, от которой мы уходим.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Голова без экрана — @f1</title>
</head>
<body>
<div data-example="headless" class="h-screen w-screen overflow-auto bg-base-100 text-base-content">
<div class="mx-auto flex max-w-4xl flex-col gap-4 p-8">
<header class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold">Голова без экрана</h1>
<p class="text-sm opacity-80">
Книга открывается, правится и читается из Node — без единого обращения к DOM. Значение
после правки пример не знает, а СПРАШИВАЕТ: считает не оболочка, а тот, кто стоит за
швом контракта (здесь — заглушка ядра, с настоящим ядром — мост хоста).
</p>
</header>
<div class="alert alert-info" data-headless-run>
<span>
Пример выполняется в Node, а не в этой странице:
<code class="font-mono">npm run example:headless</code>. Страница показывает его код —
тот самый файл, который запускает команда и стережёт автоматическая проверка.
</span>
</div>
<section class="flex flex-col gap-2">
<h2 class="text-sm font-semibold uppercase opacity-70" id="headless-name"></h2>
<pre
class="max-h-[32rem] overflow-auto rounded-lg bg-base-200 p-4 text-xs leading-relaxed"
data-headless-source
><code id="headless-code"></code></pre>
</section>
<section class="flex flex-col gap-2">
<h2 class="text-sm font-semibold uppercase opacity-70" id="trap-name"></h2>
<p class="text-sm opacity-80">
Ловушка: имена DOM объявлены так, что любое обращение к ним роняет пример. Поэтому
зелёный прогон означает не «в Node ничего не упало», а «к документу никто не
обращался».
</p>
<pre
class="max-h-[24rem] overflow-auto rounded-lg bg-base-200 p-4 text-xs leading-relaxed"
data-headless-trap
><code id="trap-code"></code></pre>
</section>
</div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

Точка входа: здесь лист создаётся и сюда же приходят события.

src/main.ts
/* Исходники берутся ФАЙЛАМИ, а не переписываются в разметку: копия кода на
странице разошлась бы с настоящим примером на первой же правке, и заметить
это было бы нечем. `?raw` отдаёт ТОТ ЖЕ файл, который запускает
`npm run example:headless` и гоняет наша автоматическая проверка. */
import headless from "./headless.ts?raw";
import trap from "./no-dom.ts?raw";
import { hostAsks } from "./host.js";
import "./styles.css";
/**
* ГОЛОВА БЕЗ ЭКРАНА — И ПОЧЕМУ ЭТА СТРАНИЦА НИЧЕГО НЕ СЧИТАЕТ.
*
* Пример живёт в Node, и в этом всё его содержание. Запустить его здесь, в
* браузере, значило бы показать ровно то, чего он не делает: страница — это
* DOM, а обещание примера — «без единого обращения к DOM».
*
* Поэтому единственное, что делает этот модуль, — кладёт текст двух файлов в
* два блока. Ни ядра, ни листа на странице нет, и это правильный ответ.
*/
hostAsks();
document.getElementById("headless-name")!.textContent = "src/headless.ts";
document.getElementById("headless-code")!.textContent = headless;
document.getElementById("trap-name")!.textContent = "src/no-dom.ts";
document.getElementById("trap-code")!.textContent = trap;

Сторона хозяина страницы: что приложение отвечает листу и что от него получает.

src/host.ts
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
/**
* ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык.
*
* У встраивающего это уже есть своё: тему он знает из настроек, язык — из
* профиля пользователя. Здесь и то и другое читается из адреса
* (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было
* дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком,
* недоступна ни ссылке, ни автоматической проверке.
*
* ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не
* лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает
* СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном.
*
* НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром
* страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от
* арабского языка интерфейса не переворачивается. Это не пропуск, а решение
* (`locale.ts`).
*/
export function hostAsks(): { readonly theme: "light" | "dark"; readonly locale: Locale } {
const params = new URLSearchParams(location.search);
const theme = params.get("theme") === "dark" ? "dark" : "light";
const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
document.documentElement.setAttribute("data-theme", theme);
/* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */
setLocale(locale);
applyLocaleAttributes(document.documentElement, locale);
return { theme, locale };
}

Оформление страницы вокруг листа.

src/styles.css
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ.
Собственное оформление лист приносит в теневые корни сам, но утилиты
раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без
него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл
замера у сетки без размеров). У потребителя вне репозитория это
`@fastoffices/formula-kit` из его node_modules. */
@import "@fastoffices/formula-kit"; /* см. примечание выше */
html,
body {
height: 100%;
margin: 0;
}

Из каких пакетов собран пример.

package.json
{
"name": "f1-example-headless",
"private": true,
"type": "module",
"description": "Книга открывается, правится и читается из Node — без единого обращения к DOM.",
"scripts": {
"dev": "vite",
"build": "vite build",
"headless": "tsx src/headless.ts"
},
"dependencies": {
"@f1/facade": "*",
"@f1/contract": "*",
"@f1/i18n": "*"
},
"devDependencies": {
"tsx": "^4.0.0",
"vite": "^7.0.0"
}
}

Файл примера.

src/headless.ts
/* ЛОВУШКА DOM СТАВИТСЯ ПЕРВОЙ — раньше пакетов: импорты ES вычисляются по
порядку и все до тела модуля. Подробности — в самом файле. */
import "./no-dom.js";
import { createF1 } from "@f1/facade";
import { createMockTransport } from "@f1/contract";
/**
* ГОЛОВА БЕЗ ЭКРАНА — КНИГА БЕЗ ЕДИНОГО ОБРАЩЕНИЯ К DOM.
*
* npm run example:headless
*
* Пример без экрана стоит у нас почти даром — граница «пакет без суффикса
* `-ui` не касается DOM» уже проведена и проверяется разбором кода, — и потому он
* ДОКАЗЫВАЕТ инвариант, а не обещает его: скрипт открывает книгу через SDK,
* читает окно, вносит правку и печатает то, что ответили за швом контракта.
* Любое обращение к `document`/`window` роняет его на месте (`no-dom.ts`).
*
* ЗАЧЕМ ЭТО ВСТРАИВАЮЩЕМУ. Ровно так живут сценарии на сервере, проверки в
* конвейере и обработка книги без окна: интерфейса нет, а книга нужна.
*
* ИНВАРИАНТ №1 ВИДЕН ЗДЕСЬ ЛУЧШЕ, ЧЕМ ГДЕ-ЛИБО. Пример ничего не считает сам:
* значение ячейки после правки он не «знает», а СПРАШИВАЕТ — то, что ответили
* ЗА ШВОМ КОНТРАКТА на последний запрос окна.
*
* КТО ИМЕННО ОТВЕЧАЕТ — НАЗЫВАЕТСЯ ВСЛУХ, и это не мелочь стиля. Ядра под
* рукой нет: его место занимает заглушка контракта (`createMockTransport`), и
* печатать «ответило ядро» значило бы приписать ядру чужую работу — тот же
* fake-green, ради запрета которого заведён инвариант №3, только в словах, а не
* в кнопке. Заглушка тоже ничего не считает: помнит сказанное и отвечает
* конвертом той же формы, что и Rust. Поэтому весь остальной вывод говорит про
* ШОВ («спросили», «отправили»), а имя исполнителя стоит ОДНОЙ строкой рядом с
* транспортом: заменят его на `wasm` — правка ровно там же.
*/
const WINDOW = { top: 0, left: 0, rows: 20, cols: 6 } as const;
/**
* Книга на месте ядра. Значения выдаются по требованию — как их выдал бы Rust
* на запрос окна; никакой второй модели документа здесь нет.
*/
const transport = createMockTransport({
cellAt: (row, col) => (col === 0 ? `строка ${row + 1}` : `${(row + 1) * (col + 1)}`),
});
const EXECUTOR = "заглушка ядра (createMockTransport)";
const f1 = createF1({ transport });
/* 1. ОТКРЫТЬ ОКНО. Работа идёт от ОКНА, а не от размера книги: загружается
двадцать строк, а не миллион. */
await f1.loadAsync(WINDOW);
const sheet = f1.getActiveWorkbook().getActiveSheet()!;
const before = sheet.getRange("A1")?.getValue();
/* 2. ПОПРАВИТЬ. Правка синхронна НАМЕРЕНИЕМ и асинхронна результатом: цепочка
ставит её в очередь, а книга меняется ОТВЕТОМ из-за шва — поэтому `flushAsync`
здесь не украшение, а единственный момент, когда правка существует. */
sheet.getRange("C1")?.setValue("42");
const sent = await f1.flushAsync();
if (sent !== true) {
console.error(`отказ за швом контракта: ${sent.reason}`);
process.exit(1);
}
/* 3. ПРОЧИТАТЬ ОБРАТНО — ЗА ШВОМ, а не у себя. */
const after = sheet.getRange("C1")?.getValue();
const requests = (globalThis as typeof globalThis & { __f1ContractRequests?: unknown[] })
.__f1ContractRequests?.length;
console.log("книга без экрана: @f1/facade поверх контракта, DOM запрещён ловушкой");
console.log(`за швом контракта: ${EXECUTOR}`);
console.log(`окно: строк ${WINDOW.rows}, столбцов ${WINDOW.cols} — а не вся книга`);
console.log(`A1 спросили → ${before}`);
console.log(`C1 отправили ← 42`);
console.log(`C1 спросили → ${after}`);
console.log(`запросов за шов контракта: ${requests}`);
console.log("DOM не тронут: ловушка не сработала ни разу");
/* ПРОВЕРКА, А НЕ ПЕЧАТЬ. Пример, который печатает что угодно и всегда выходит
нулём, доказывает только то, что он запустился. Эти два утверждения — и есть
содержание примера, поэтому они решают код возврата: ровно этот скрипт
запускает и наша автоматическая проверка. */
if (before !== "строка 1" || after !== "42") {
console.error(`за швом контракта ответили не тем: A1 «${before}», C1 «${after}»`);
process.exit(1);
}

Файл примера.

src/no-dom.ts
/**
* ЛОВУШКА DOM — ДОКАЗАТЕЛЬСТВО ВМЕСТО ОБЕЩАНИЯ.
*
* Пример без экрана мог бы просто «работать в Node» — и этого было бы мало:
* в Node `document` не существует, поэтому код, который его НЕ трогает, и код,
* который трогает его под `typeof document !== "undefined"`, выглядят
* одинаково — оба молчат. Молчание — не доказательство.
*
* Поэтому здесь имена DOM ОБЪЯВЛЯЮТСЯ, и каждое из них — ловушка: любое
* обращение, даже осторожное, через `typeof`, поднимает исключение и роняет
* пример. Значит зелёный прогон означает ровно то, что написано на вывеске:
* ни одного обращения к DOM за весь путь «открыть книгу → прочитать окно →
* поправить → прочитать снова».
*
* Модуль импортируется ПЕРВЫМ и отдельным файлом намеренно: импорты ES
* вычисляются в порядке объявления и ВСЕ до тела модуля, поэтому ловушка,
* поставленная строкой кода в самом примере, опоздала бы ровно на то, что
* важно, — на выполнение импортируемых пакетов.
*
* Границу «пакет без суффикса не касается DOM» стережёт и разбор исходников
* отдельной проверкой. Эти двое проверяют РАЗНОЕ и оба нужны: разбор
* видит наш код, ловушка — то, что правда выполнилось, включая чужие
* зависимости.
*/
/**
* Что запрещено: входные двери в документ и в окно.
*
* СПИСОК КОРОЧЕ, ЧЕМ У РАЗБОРА (`tools/dom-free.mjs`), И ЭТО ИЗМЕРЕНО, А НЕ
* ослаблено «на всякий случай». В первой редакции здесь стоял и
* `requestAnimationFrame` — ловушка сработала сразу, но не на нашем коде:
* `preact/hooks` при загрузке выполняет `typeof requestAnimationFrame`
* (`hooks.mjs`, объявление `var k`), чтобы уметь работать в обоих мирах, а
* тянется он из `@f1/sheets`. Проверка на существование ИМЕНИ доступа к
* документу не даёт, и ронять на ней пример значило бы объявить нарушением
* чужую совместимость.
*
* Разница между двумя списками названа вслух: разбор смотрит НАШИ исходники и
* вправе быть строже — там `requestAnimationFrame` действительно означает
* работу с кадром браузера; ловушка смотрит на ВЫПОЛНЕНИЕ вместе с чужими
* зависимостями, и её предмет — доступ к документу и окну.
*/
const FORBIDDEN = [
"document",
"window",
"customElements",
"HTMLElement",
"localStorage",
"sessionStorage",
] as const;
export function trapDom(): void {
for (const name of FORBIDDEN) {
Object.defineProperty(globalThis, name, {
configurable: true,
get(): never {
throw new Error(
`пример без экрана обратился к «${name}» — а обещал, что не будет; ` +
"смотри no-dom.ts",
);
},
});
}
}
trapDom();

Ассистент документации

Ответ собран из документации и может быть неточным — сверяйтесь с источниками.