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

Помощники ведут лист

До десяти внешних участников, каждый в своём процессе: ходит по своей колонке, выделяет участок и вписывает итог — намерениями через тот же канал; отказ в лишнем виден и ему, и человеку; не запустили — страница так и говорит.

до десяти внешних участников, каждый в своём процессе: ходит по своей колонке, выделяет участок и вписывает итог — намерениями через тот же канал; отказ в лишнем виден и ему, и человеку; не запустили — страница так и говорит
открыть в новой вкладке ↗Работающий пример. Ниже — его код целиком, файл за файлом.

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

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

index.html
<!doctype html>
<!--
ПОМОЩНИКИ ВЕДУТ ЛИСТ — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе
целиком. Каждый помощник живёт в СВОЁМ соседнем процессе (воркер): своя
память, свой модуль и ни одной общей переменной со страницей — до листа он
достаёт ровно через канал, и никак иначе.
СКОЛЬКО ИХ — ПАРАМЕТР АДРЕСА (`?assistants=10`), а не константа: список
и слой курсоров обязаны держать и десятерых.
ПОКА ИХ НЕ ЗАПУСТИЛИ, СТРАНИЦА ГОВОРИТ ЭТО ПРЯМО. Нарисованный помощник,
которого нет, — это ровно тот fake-green, ради запрета которого написан
инвариант №3.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Помощники ведут лист — @f1</title>
</head>
<body>
<div data-example="driver" class="flex h-screen w-screen flex-col bg-base-100 text-base-content">
<header class="flex items-start gap-3 border-b border-base-300 px-4 py-2">
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold">Помощники ведут лист</div>
<div class="text-xs opacity-70">
Помощники — программы в СОСЕДНИХ процессах — присутствуют здесь так же, как люди:
своё имя, свой цвет, свой курсор. Ведут они не мышью, а НАМЕРЕНИЯМИ: решает их наш
композиционный корень, и правка идёт тем же путём, что у ваших пальцев. Каждому
разрешено писать и звать одну команду; всё остальное — отказ, и отказ вы увидите.
</div>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<!-- СКОЛЬКО ИХ — ССЫЛКАМИ НА ТЕ ЖЕ АДРЕСА (`?assistants=10`), а не
кнопками со состоянием в памяти: на любое состояние показа можно
дать ссылку, и обход ходит туда же, куда человек. -->
<div class="join" data-driver-size></div>
<button type="button" class="btn btn-primary btn-sm" data-driver-connect>
Подключить помощников
</button>
<button type="button" class="btn btn-ghost btn-sm" data-driver-overreach disabled>
Попросить лишнего
</button>
</div>
</header>
<!-- ЧЕСТНОСТЬ НА ЭКРАНЕ: строка говорит то, что видно КАНАЛУ, а не то,
нажимали ли кнопку. Помощник, упавший при запуске, обязан считаться
неподключённым — иначе страница врёт ровно тогда, когда это дороже
всего. -->
<!-- РОСТ СТРОКИ НЕ ДВИГАЕТ ЛИСТ: меняется ТОЛЬКО короткое начало
(`data-driver-said`), а объяснение стоит здесь с первой отрисовки и не
зависит от того, подключены помощники или нет. Раньше сюда
писалась целиком то короткая, то длинная строка: на 1280 и 1440 она
переносилась на вторую строку в момент подключения, и лист уезжал вниз
на 16,8 px под рукой человека (замер закреплён проверкой
«ЛИСТ НЕ УЕЗЖАЕТ ПОД РУКОЙ»). Это то же правило, что и у
подчёркивания активной вкладки: показ состояния не двигает соседей. -->
<div class="border-b border-base-300 px-4 py-1 text-xs" data-driver-status>
<span data-driver-said>Помощники не подключены.</span>
Каждый ведёт СВОЮ колонку: ходит по ней, выделяет участок и вписывает итог.
Книга здесь одна, поэтому их правки видны; согласования правок между разными
книгами у нас нет — см. пример «двое в одном документе».
</div>
<!-- ВКЛАДКА-ПУЛЬТ (`?role=drive`) ЛИСТА НЕ ДЕРЖИТ: у неё нет книги вовсе,
и пустая рамка на её месте показывала бы документ, которого здесь не
открыто. Говорит она это прямо — по тому же правилу, по которому
страница говорит «помощники не подключены». -->
<div class="hidden border-b border-base-300 px-4 py-2 text-xs" data-driver-role>
Эта вкладка — ПУЛЬТ ПОМОЩНИКОВ, листа на ней нет. Помощники ведут лист,
открытый в другом окружении, через ретранслятор: намерение уезжает по
сокету, приговор приезжает обратно и виден ниже.
</div>
<!-- ПРИГОВОРЫ ГЛАЗАМИ ВЕДУЩЕГО. Человеку у листа отказ показывает сам лист;
здесь видно то, что услышала ДРУГАЯ сторона, — включая случай, которого
до провода не существовало: ответа нет вовсе. -->
<!-- МЕСТО ЗАНЯТО ЗАРАНЕЕ, А НЕ ПО МЕРЕ ПОЯВЛЕНИЯ ПРИГОВОРОВ. Высота
ПОСТОЯННАЯ (`h-28`), а не `max-h-32`: журнал стоит НАД листом в том же
столбце, и каждая новая строка сдвигала лист вниз на 16,8 px — за
девять секунд работы одного помощника набегало 50,4 px, то есть три
строки листа. Человек щёлкал B7, смотрел на помощников, щёлкал туда же
и попадал в B4 — это замерено.
Почему здесь ЗАРЕЗЕРВИРОВАННОЕ МЕСТО, а не оверлей, как у полосы
отказов: журнал — не показ состояния поверх листа, а вторая половина
этой страницы, читать её человек будет постоянно; оверлей закрыл бы
ячейки ровно тогда, когда приговоров много. Пустая рамка при этом
подписана — иначе она выглядит поломкой, а не заготовленным местом. -->
<div class="shrink-0 px-4 py-1" data-driver-log>
<div class="text-xs opacity-50">Приговоры помощников</div>
<ul class="h-28 overflow-auto" data-driver-verdicts></ul>
</div>
<div class="min-h-0 flex-1 p-2" data-driver-sheet>
<div class="flex h-full min-h-0 flex-col rounded-card border border-base-300">
<div class="seat flex min-h-0 flex-1 flex-col"></div>
</div>
</div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

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

src/main.ts
import { createSheetsCore, type SheetsCore } from "@f1/preset-sheets-core";
import {
PRESENCE_TIMING,
broadcastLink,
socketLink,
type DriveGrant,
type Peer,
type DriveVerdict,
type PeerId,
type PresenceLink,
} from "@f1/presence";
import { book } from "./book.js";
import { crewOf, crewSize } from "./crew.js";
import { hostAsks } from "./host.js";
import type { AssistantBrief, FromAssistant } from "./assistant.worker.js";
import "./styles.css";
/**
* ПОМОЩНИКИ ВЕДУТ ЛИСТ — И НИ ОДНОГО ВЫДУМАННОГО УЧАСТНИКА.
*
* Пример отвечает на два вопроса сразу: может ли программа-помощник вести
* лист через сокет так, чтобы её курсор был виден человеку, и что происходит,
* когда таких помощников не один, а десять.
*
* ПОМОЩНИК ЖИВЁТ ЗА ПРЕДЕЛАМИ ЭТОЙ СТРАНИЦЫ. Канал
* выбирается адресом (`?link=socket&relay=…`), а `?role=drive` открывает вкладку
* БЕЗ ЛИСТА — только помощники и их приговоры. Значит дорога проверяется целиком:
* лист в одном окружении, ведущий в другом, между ними настоящий сокет и наш
* ретранслятор. Ни строки продукта от этого не изменилось — сменилась одна
* реализация порта.
*
* ЧТО ЗДЕСЬ ПРОИСХОДИТ. Каждый помощник живёт в СВОЁМ СОСЕДНЕМ ПРОЦЕССЕ
* (воркер): своя память, свой модуль, ни одной общей переменной с этой
* страницей. Он присутствует так же, как человек — своим именем, цветом и
* курсором, — и ВЕДЁТ лист намерениями. Решает их наш композиционный корень:
* правка идёт тем же путём, что у пальцев человека, и приходит в тот же транспорт
* контракта.
*
* ЧТО ЗДЕСЬ ОБЩЕЕ, А ЧТО НЕТ, — И ЭТО НАПИСАНО НА ЭКРАНЕ. Книга ОДНА, и
* помощники ведут именно её: поэтому их правки видны — не потому, что мы
* что-то передали между сеансами. Согласования правок между РАЗНЫМИ книгами у
* нас нет, и пример его не изображает (см. `?example=presence`, где две рамки
* действительно не видят правок друг друга). Лучший ответ здесь — сказать
* прямо, а не подделать.
*
* ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Никакой языковой модели по сети продукт не зовёт:
* участник приходит через канал, кем бы он ни был. Поставьте на место воркера
* что угодно — свой сервис, чужую программу, настоящую модель — договор не
* изменится ни на строку.
*
* ПОКА ПОМОЩНИКОВ НЕ ЗАПУСТИЛИ, СТРАНИЦА ГОВОРИТ ЭТО ПРЯМО, и говорит по
* КАНАЛУ, а не по факту нажатия кнопки: помощник, упавший при запуске, обязан
* считаться неподключённым. Нарисованный участник, которого нет, — это
* fake-green (инвариант №3), и здесь он был бы самым соблазнительным.
*/
const { locale, link: kind, relay, role, surface: askedSurface } = hostAsks();
const SPACE = "пример-помощника";
const HUMAN: Peer = { id: "human", name: "Анна Петрова" };
const CREW = crewOf(crewSize(location.search));
const CREW_IDS = new Set<PeerId>(CREW.map((member) => member.id));
/**
* ЧТО ПОМОЩНИКАМ МОЖНО — РЕШАЕТ ХОЗЯИН, И ПОИМЁННО.
*
* Писать — да: за этим их и позвали. Команды — ОДНА, названная: «можно всё»
* означало бы, что помощник умеет снять защиту и закрыть книгу, а узнали бы мы
* об этом по последствиям. Кто не в составе — не ведёт ничего (`null`), и это
* умолчание, а не забывчивость: канал знает тот, кто знает ключ, и права
* ведения не должны следовать из знания ключа.
*/
const grantFor = (from: PeerId): DriveGrant | null =>
CREW_IDS.has(from) ? { write: true, commands: ["clipboard.copy"] } : null;
/* КАНАЛ ВЫБИРАЕТ ХОЗЯИН СТРАНИЦЫ, а не лист и не помощник: продукт видит порт,
а не сокет (`PresenceLink`). Ту же строку выбора делает вкладка помощников —
и делает её СВОИМ кодом, потому что она другая программа. */
const linkOf = (): PresenceLink =>
kind === "socket" ? socketLink({ space: SPACE, url: relay }) : broadcastLink({ space: SPACE });
const link = linkOf();
/* ЛИСТ ЕСТЬ НЕ ВСЕГДА: `?role=drive` — это пульт помощников, у него книги нет
вовсе. Нарисовать пустую рамку значило бы показать документ, которого на этой
вкладке не открыто. */
const core: SheetsCore | null =
role === "sheet"
? createSheetsCore({
mount: document.querySelector<HTMLElement>(".seat")!,
transport: book(),
locale,
rows: 200,
cols: 26,
presence: { link, self: HUMAN, drive: { grantFor } },
})
: null;
if (role === "drive") {
document.querySelector("[data-driver-sheet]")?.remove();
document.querySelector("[data-driver-role]")?.classList.remove("hidden");
}
const status = document.querySelector<HTMLElement>("[data-driver-status]")!;
/**
* МЕНЯЕТСЯ ТОЛЬКО КОРОТКОЕ НАЧАЛО СТРОКИ, а объяснение стоит в разметке всегда.
*
* Не ради краткости кода: полная строка при подключении становилась вчетверо
* длиннее, переносилась на вторую строку (на 1280 и 1440 — замер) и толкала лист
* вниз на 16,8 px в тот самый момент, когда человек смотрит на помощников.
* Правило набора: показ состояния не двигает геометрию соседей.
*/
const said = document.querySelector<HTMLElement>("[data-driver-said]")!;
const connect = document.querySelector<HTMLButtonElement>("[data-driver-connect]")!;
const overreach = document.querySelector<HTMLButtonElement>("[data-driver-overreach]")!;
const size = document.querySelector<HTMLElement>("[data-driver-size]")!;
const verdicts = document.querySelector<HTMLElement>("[data-driver-verdicts]")!;
connect.textContent = `Подключить помощников (${CREW.length})`;
/* ЧИСЛО — ССЫЛКАМИ НА ТЕ ЖЕ АДРЕСА, а не кнопками с состоянием в памяти: вид,
который нельзя ни показать, ни воспроизвести, не показывает ничего. Тем же
правилом живут тема и язык витрины. */
for (const count of [1, 4, 10]) {
const link = document.createElement("a");
const params = new URLSearchParams(location.search);
params.set("assistants", String(count));
link.href = `?${params.toString()}`;
link.className = `btn btn-xs${count === CREW.length ? " btn-active" : " btn-ghost"}`;
link.textContent = String(count);
link.dataset.driverSize = String(count);
size.append(link);
}
/**
* ЧЕСТНОСТЬ СТРОКИ ДЕРЖИТСЯ КАНАЛОМ, А НЕ ПАМЯТЬЮ О НАЖАТИИ.
*
* Страница слушает ТОТ ЖЕ канал и считает помощника подключённым ровно пока от
* него приходят вести — по тем же временам, по которым его считает живым список
* присутствующих (`PRESENCE_TIMING.forgetAfterMs`). Переменной «мы их
* запустили» здесь нет намеренно: она осталась бы правдой и после того, как
* помощник упал.
*/
const heardAt = new Map<PeerId, number>();
link.listen((message) => {
if (message.kind !== "join" && message.kind !== "state") return;
if (!CREW_IDS.has(message.state.peer.id)) return;
heardAt.set(message.state.peer.id, Date.now());
say();
});
function say() {
const now = Date.now();
const here = [...heardAt.values()].filter((seen) => now - seen < PRESENCE_TIMING.forgetAfterMs);
said.textContent =
here.length === 0
? "Помощники не подключены."
: `Помощников на листе: ${here.length} из ${CREW.length}.`;
status.dataset.driverHere = here.length > 0 ? "yes" : "no";
status.dataset.driverCount = String(here.length);
overreach.disabled = here.length === 0;
}
say();
setInterval(say, PRESENCE_TIMING.moveThrottleMs * 4);
/**
* ЧТО УСЛЫШАЛ САМ ВЕДУЩИЙ — И ПОЧЕМУ ЭТО ВИДНО ЗДЕСЬ, А НЕ ТОЛЬКО В ЖУРНАЛЕ.
*
* Человеку РЯДОМ С ЛИСТОМ отказ показывает сам лист (полоса отказов), и это
* так и задумано. А вот сторона ВЕДУЩЕГО не показывала ничего: приговоры
* уходили в консоль. Пока провод был внутри страницы, разницы не было; по сети
* появился случай, которого раньше не существовало, — ретранслятор упал, и
* приговора нет вовсе. Тишина и «отказано» выглядят на консоли одинаково: никак.
*
* Поэтому приговоры видны: `ok` тоже, а не только отказы. Список отказов без
* согласий говорил бы, что помощник работает только когда ошибается.
*/
const VERDICTS_SHOWN = 12;
function note(verdict: DriveVerdict) {
const line = document.createElement("li");
line.dataset.driverVerdict = verdict.ok ? "ok" : (verdict.why ?? "refused");
line.className = `text-xs ${verdict.ok ? "opacity-70" : "text-error"}`;
line.textContent = verdict.ok
? `${verdict.id}: принято`
: `${verdict.id}: ${verdict.why}${verdict.detail ? ` — ${verdict.detail}` : ""}`;
verdicts.prepend(line);
/* ЖУРНАЛ НЕ РАСТЁТ БЕЗ КОНЦА. Помощники работают, пока открыта страница
(по одному итогу за проход, ~17 приговоров в минуту на каждого — замер),
и неограниченный список за час превратился бы в десятки тысяч узлов.
Видно последнее: чем кончилось только что. */
while (verdicts.childElementCount > VERDICTS_SHOWN) verdicts.lastElementChild?.remove();
}
const crew: Worker[] = [];
connect.addEventListener("click", () => {
if (crew.length > 0) return;
/* ИМЯ ЛИСТА — ОТ ТОГО, КТО ЗНАЕТ, ЧТО ОТКРЫТО: у вкладки с книгой это сама
книга, у пульта — тот, кто его открыл (`?surface=…`). Помощник не гадает. */
const surface = core ? core.f1.getActiveWorkbook().getActiveSheet()!.getName() : askedSurface;
for (const member of CREW) {
/* ЗАПУСКАЕТ ИХ ХОЗЯИН СТРАНИЦЫ, а не лист: чужой процесс — не наша забота и
не наше право. Лист узнаёт о помощнике ровно тогда, когда тот объявится
в канале, — как и о любом другом участнике. */
const worker = new Worker(new URL("./assistant.worker.js", import.meta.url), { type: "module" });
worker.addEventListener("message", (event: MessageEvent<FromAssistant>) => {
/* Приговоры печатаются в журнал только для наглядности: человеку их
показывает сам лист полосой отказов. */
if (event.data.kind === "verdict") {
console.info("[помощник]", event.data.verdict);
note(event.data.verdict);
}
});
worker.postMessage({
kind: "start",
space: SPACE,
/* КАНАЛ ПОМОЩНИКУ НАЗЫВАЕТ ТОТ, КТО ЕГО ЗАПУСТИЛ: своего представления о
том, где искать комнату, у чужой программы нет. */
link: kind,
relay,
/* Имя листа помощник не выдумывает: ему говорит хозяин, потому что хозяин
знает, что открыто. */
surface,
/* КОГО ВЕДЁМ — этого человека: та же личность, которой лист присутствует
и которой подписаны его собственные правки. */
owner: HUMAN.id,
brief: member satisfies AssistantBrief,
});
crew.push(worker);
}
connect.disabled = true;
});
overreach.addEventListener("click", () => {
/* Лишнего просит ОДИН — первый: десять одинаковых отказов подряд не сказали
бы человеку ничего сверх одного, а полоса показывает последний отказ
КАЖДОГО участника, и десять строк закрыли бы половину листа. */
crew[0]?.postMessage({ kind: "ask", what: "overreach" });
});
import.meta.hot?.dispose(() => {
for (const worker of crew) worker.terminate();
core?.destroy();
link.close();
});

Книга примера: данные, с которыми лист открывается.

src/book.ts
import { createMockTransport } from "@f1/contract";
/**
* КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА.
*
* Транспорт контракта — единственное место, где лист разговаривает с ядром
* (инвариант №1). В продукте здесь стоит `wasm` или мост хоста.
*
* ВАЖНО ДЛЯ ЭТОГО ПРИМЕРА: транспорт ОДИН, и правка помощника приходит в него тем
* же путём, что правка человека. Именно поэтому написанное помощником видно в
* ячейке — не потому, что мы нарисовали его текст поверх листа.
*/
export const book = () =>
createMockTransport({
cellAt: (row, col) => (col === 0 ? `строка ${row + 1}` : `${(row + 1) * (col + 1)}`),
});

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

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

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

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

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

package.json
{
"name": "f1-example-driver",
"private": true,
"type": "module",
"description": "Внешний участник ведёт лист: помощник живёт в СОСЕДНЕМ процессе, ходит по листу своим курсором, выделяет диапазон и пишет значение — а его отказ виден и ему, и человеку рядом.",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@f1/preset-sheets-core": "*",
"@f1/presence": "*",
"@f1/contract": "*",
"@f1/i18n": "*"
},
"devDependencies": {
"vite": "^7.0.0"
}
}

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

src/assistant.worker.ts
/**
* ПОМОЩНИК — ЧУЖАЯ ПРОГРАММА В СОСЕДНЕМ ПРОЦЕССЕ.
*
* Этот файл НЕ ЧАСТЬ ЛИСТА и не часть страницы. У него своя память, свой модуль
* и ни одной общей переменной с той стороной: он достаёт до документа ровно
* через канал и никак иначе. Ровно так же будет устроен участник, живущий на
* другой машине, — сменится только реализация канала.
*
* ПОЧЕМУ ВОРКЕР, А НЕ ВТОРАЯ ВКЛАДКА. Вкладку нельзя открыть за человека, а
* пример обязан работать и под обходом. Воркер — настоящий отдельный сеанс
* исполнения: у него нет ни `document`, ни доступа к нашим объектам, и всё, что
* он умеет, он умеет через `BroadcastChannel`. Никакого послабления это не даёт:
* запрети ему канал — и он не сможет ничего.
*
* ГДЕ ОН ЖИВЁТ. В соседнем процессе, но канал ему
* называет запустивший, и по `?link=socket` это НАСТОЯЩИЙ СОКЕТ: страница с
* листом при этом может быть в другом окружении, в другом браузере или на
* другой машине. Помощника это не касается ни строкой: он видит порт.
*
* ЧЕМ ОН ЯВЛЯЕТСЯ И ЧЕМ НЕ ЯВЛЯЕТСЯ. Он ведёт себя как ИИ-помощник: получает
* просьбу, решает, что сделать, и объявляет намерения. Языковой модели внутри
* него нет — и это НЕ упрощение примера, а граница продукта: кем бы ни был
* участник по ту сторону канала, наш код его не зовёт и знать о нём не должен.
* Поставьте сюда что угодно — договор не изменится.
*
* ЧТО ОН ОБЪЯВЛЯЕТ. Своё присутствие (три вести — как у человека) и намерения
* (`write`, `command`). Ни одной операции контракта отсюда не уходит: помощник
* ПРОСИТ, а решает композиционный корень.
*
* ЭКЗЕМПЛЯР — ОДИН НА ПОМОЩНИКА. Десять помощников — это десять
* воркеров, а не один со списком: общий процесс на всех был бы одним сеансом,
* притворяющимся десятью, и первое же «а что если один упадёт» это вскрыло бы.
*/
import {
PRESENCE_TIMING,
broadcastLink,
createDriveWire,
createPresenceRoster,
socketLink,
type DriveVerdict,
type PresenceLink,
} from "@f1/presence";
export interface AssistantBrief {
readonly id: string;
readonly name: string;
readonly col: number;
readonly from: number;
}
type ToAssistant =
| {
readonly kind: "start";
readonly space: string;
/**
* Каким каналом идти. Говорит запустивший — у чужой программы своего
* представления о том, где искать комнату, нет.
*
* `socket` — это и есть весь смысл среза: помощник достаёт до листа,
* открытого в ДРУГОМ окружении, и делает это через ретранслятор. Ни одной
* строки самого помощника от смены канала не меняется: он видит порт.
*/
readonly link: "broadcast" | "socket";
readonly relay: string;
readonly surface: string;
/**
* КОГО ОН ВЕДЁТ — хозяин листа, его личность.
*
* Говорит её тот, кто помощника запустил, ровно как и имя листа: у чужой
* программы нет способа «догадаться», в чью книгу она пишет, а в комнате
* ретранслятора хозяев столько, сколько окон открыло документ.
*/
readonly owner: string;
readonly brief: AssistantBrief;
}
| { readonly kind: "ask"; readonly what: "overreach" };
export type FromAssistant =
| { readonly kind: "ready"; readonly id: string }
| { readonly kind: "verdict"; readonly verdict: DriveVerdict };
const COLUMNS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const ROWS = 22;
let link: PresenceLink | null = null;
let roster: ReturnType<typeof createPresenceRoster> | null = null;
let wire: ReturnType<typeof createDriveWire> | null = null;
let surface = "";
let me: AssistantBrief | null = null;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const at = (col: number, row: number) => `${COLUMNS[col]}${row}`;
self.addEventListener("message", (event: MessageEvent<ToAssistant>) => {
const message = event.data;
if (message.kind === "start") void start(message);
if (message.kind === "ask" && message.what === "overreach") overreach();
});
async function start(order: Extract<ToAssistant, { kind: "start" }>) {
if (roster) return;
surface = order.surface;
me = order.brief;
link =
order.link === "socket"
? socketLink({ space: order.space, url: order.relay })
: broadcastLink({ space: order.space });
roster = createPresenceRoster({ link, self: { id: me.id, name: me.name } });
wire = createDriveWire({ link, self: me.id, to: order.owner });
/* Приговоры уходят на страницу ТОЛЬКО ради журнала. Человеку их показывает
сам лист полосой отказов, и показывает независимо от того, слушает ли
кто-нибудь эту страницу. */
wire.hear((verdict) => self.postMessage({ kind: "verdict", verdict } satisfies FromAssistant));
roster.start();
/* Тик — половина дробления движения, как и у страницы: он досылает отложенное
и подаёт признак жизни. Своего таймера у списка нет намеренно. */
setInterval(() => roster?.tick(), PRESENCE_TIMING.moveThrottleMs / 2);
self.postMessage({ kind: "ready", id: me.id } satisfies FromAssistant);
await work();
}
/**
* СЦЕНАРИЙ ОДНОГО ПОМОЩНИКА: смотрит свою колонку, выделяет в ней участок,
* пишет итог — и продолжает смотреть.
*
* ОСМЫСЛЕННО, А НЕ СЛУЧАЙНЫМИ КООРДИНАТАМИ (требование к примеру: чтобы они реально
* что-то делали по сценарию»). Дёрганье по случайным ячейкам выглядит живым
* ровно одну секунду, после чего человек перестаёт понимать, что происходит, —
* и перестаёт замечать, когда показ сломался. Здесь видно работу: каждый идёт
* по своей колонке сверху вниз, останавливается на участке и вписывает итог.
*
* ПРАВКА ИДЁТ РАНО, А ХОДЬБА ПРОДОЛЖАЕТСЯ ПОСЛЕ: человеку не приходится ждать
* результата, а замер цены кадра получает ХОДЯЩИХ соседей, а не дошедших.
* Помощник, замерший в конце пути, стоит ровно столько же, сколько неподвижный.
*
* ПОЧЕМУ РАБОТА БЕСКОНЕЧНА, А ИТОГ ОБНОВЛЯЕТСЯ КАЖДЫЙ ПРОХОД. На десяти
* помощниках со стороны казалось, что правки в ячейках не проходят. Замер
* показал, чем это было на самом деле: правки ДОЕЗЖАЛИ (десять приговоров `ok` из десяти, и
* все десять значений видны на снимке через 14 с), но каждый помощник писал
* РОВНО ОДИН РАЗ, а через 18 секунд после запуска весь состав замирал навсегда
* — восемь проходов и конец. Смотревший дольше двадцати секунд видел
* неподвижную картину и ни одного изменения: работа, которой не видно,
* неотличима от отсутствия работы, а это ровно то, чего пример обязан не
* допускать.
*
* Поэтому проход не последний, а очередной, и каждый кончается ОБНОВЛЁННЫМ
* итогом в ТОЙ ЖЕ ячейке. Книга мусором не наполняется — довод, ради которого
* правка была однократной, сохранён буквально: ячейка одна на помощника,
* меняется в ней значение. Замер после починки: 169 приговоров за 40 с вместо
* десяти, движение курсоров до конца наблюдения вместо 18-й секунды.
*/
async function work() {
if (!roster || !wire || !me) return;
const col = me.col;
const top = me.from + 6;
for (let round = 0; ; round += 1) {
await walk(col, me.from, ROWS);
/* ВЫДЕЛЯЕТ УЧАСТОК И ДЕРЖИТ ЕГО. Охват — такой же ВЗГЛЯД, как и курсор: он
ничего не меняет в книге и потому не намерение, а обычная весть
присутствия. ДЕРЖИТ — потому что мелькнувшая на четверть секунды рамка не
читается человеком и недоказуема обходом: и то и другое требует, чтобы
состояние ПОБЫЛО. */
roster.focus({ surface, at: at(col, top), span: `${at(col, top)}:${at(col + 1, top + 4)}` });
await wait(1200);
/* И ПИШЕТ ИТОГ — вот это уже намерение: оно меняет книгу. КАЖДЫЙ ПРОХОД и
в ТУ ЖЕ ячейку: номер прохода в значении и есть то, по чему человек
видит, что работа идёт, а не кончилась (см. шапку функции). */
wire.declare({ kind: "write", at: at(col, top), input: `итог ${me.name} ${round + 1}` });
}
}
async function walk(col: number, from: number, steps: number) {
for (let step = 0; step < steps; step += 1) {
roster?.focus({ surface, at: at(col, from + (step % ROWS)) });
await wait(PRESENCE_TIMING.moveThrottleMs);
}
}
function overreach() {
/* Команда настоящая и из нашего реестра — второго словаря для внешних нет.
Отказ будет не «такой команды не бывает», а «команда не выдана»: разница
принципиальная, и её видно в приговоре. */
wire?.declare({ kind: "command", command: "workbook.protect" });
}

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

src/crew.ts
/**
* СКОЛЬКО ПОМОЩНИКОВ И КТО ОНИ — ДАННЫЕ, А НЕ КОНСТАНТА В РАЗМЕТКЕ.
*
* Помощников может быть и три, и десять, и каждый должен делать настоящую
* работу по сценарию, а не изображать её.
*
* ЧИСЛО — ПАРАМЕТР АДРЕСА (`?assistants=10`), и это принципиально:
* если на десяти что-то ломается — ярлыки налезают, дорожает кадр, путаются
* цвета, — это находка, которую называют числом, а не прячут уменьшением числа
* участников. Обход поэтому ходит на те же адреса, что человек.
*
* ОБЛАСТЬ У КАЖДОГО СВОЯ, И ЭТО НЕ УКРАШЕНИЕ. Десять курсоров, гуляющих по
* одним и тем же ячейкам, — это каша, по которой нельзя сказать, работает
* показ или нет. Своя колонка у каждого делает картину ЧИТАЕМОЙ: человек видит,
* что происходит, а спека может проверить, что участник ходит именно там, где
* ему положено.
*
* ЛИЧНОСТЬ ЗДЕСЬ — ОТ ХОЗЯИНА СТРАНИЦЫ, как и везде: в продукте эти имена
* приедут от учётных записей. Цвет не назначается — его считает `colorOf` по
* идентификатору, тем же кругом, которым помечены курсор и рамка.
*/
const NAMES = [
"Ада",
"Борис",
"Вера",
"Глеб",
"Дина",
"Егор",
"Жанна",
"Захар",
"Ирина",
"Кирилл",
] as const;
export const CREW_LIMIT = NAMES.length;
export interface CrewMember {
readonly id: string;
readonly name: string;
/**
* Столбец, в котором он работает (с нуля).
*
* Через один: соседние колонки поставили бы ярлыки имён вплотную, а
* читаемость картины — половина заказа. Десятый помощник занимает столбец
* `1 + 9*2 = 19` (T), то есть все десять умещаются в видимой части листа.
*/
readonly col: number;
/**
* С какой строки он начинает обход.
*
* РАЗНЫЕ ФАЗЫ У РАЗНЫХ ПОМОЩНИКОВ — тоже про читаемость: стартовав вместе,
* десять курсоров шли бы одной шеренгой, и ярлыки имён встали бы в один ряд
* поперёк листа. Разведённые фазы дают картину, в которой видно каждого.
*/
readonly from: number;
}
/**
* Кто выйдет на лист при заданном числе помощников.
*
* @param count сколько их просят; лишнее отсекается пределом показа
* @returns состав: у каждого своё имя, свой столбец и своя фаза обхода
*
* @example
* crewOf(3).map((member) => member.name); // ["Ада", "Борис", "Вера"]
*/
export function crewOf(count: number): readonly CrewMember[] {
const size = Math.max(1, Math.min(CREW_LIMIT, Math.trunc(count)));
return NAMES.slice(0, size).map((name, index) => ({
id: `assistant-${index + 1}`,
name,
col: 1 + index * 2,
from: 1 + ((index * 3) % 12),
}));
}
/**
* Сколько помощников просит адрес страницы.
*
* @param search строка запроса (`?assistants=10`)
* @returns число в пределах показа; по умолчанию четыре
*
* @example
* crewSize("?assistants=10"); // 10
* crewSize("?assistants=99"); // 10 — предел показа, а не ошибка
* crewSize(""); // 4
*/
export function crewSize(search: string): number {
const asked = Number(new URLSearchParams(search).get("assistants"));
/* ЧЕТЫРЕ ПО УМОЛЧАНИЮ, а не десять: страница должна открываться читаемой, а
десять — это состояние, которое ПРОСЯТ, чтобы посмотреть на предел. */
if (!Number.isFinite(asked) || asked <= 0) return 4;
return Math.min(CREW_LIMIT, Math.trunc(asked));
}

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

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