Голова без экрана
Книга открывается, правится и читается из Node — без единого обращения к DOM.
В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.
Страница, которую открывает браузер.
<!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>Точка входа: здесь лист создаётся и сюда же приходят события.
/* Исходники берутся ФАЙЛАМИ, а не переписываются в разметку: копия кода на странице разошлась бы с настоящим примером на первой же правке, и заметить это было бы нечем. `?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;Сторона хозяина страницы: что приложение отвечает листу и что от него получает.
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 };}Оформление страницы вокруг листа.
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ. Собственное оформление лист приносит в теневые корни сам, но утилиты раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл замера у сетки без размеров). У потребителя вне репозитория это `@fastoffices/formula-kit` из его node_modules. */@import "@fastoffices/formula-kit"; /* см. примечание выше */html,body { height: 100%; margin: 0;}Из каких пакетов собран пример.
{ "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" }}Файл примера.
/* ЛОВУШКА 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);}Файл примера.
/** * ЛОВУШКА 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();- Остальные примеры — Примеры F1.
- Правила, на которые ссылается код, — пять правил.
- Из чего собирается лист — Архитектура.
- Что можно вызвать сегодня — справочник Compute Contract.