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

examples/a4-embedИз чего состоит этот пример
Заголовок раздела «Из чего состоит этот пример»В рамке выше работает тот же редактор и тот же способ встраивания, но площадка поднята у нас: документ ей даёт наша демонстрационная служба. Чтобы то же самое заработало у вас, нужны три вещи — страница с местом под рамку, код подключения и ваш сервер с двумя адресами. Ровно они и показаны ниже: это цельный проект, который запускается сам, а не выдержки из него.
Смотреть его стоит в таком порядке: сначала страница, потом подключение, потом сервер. Первые два файла отвечают на вопрос «где появляется редактор», третий — на вопрос «откуда документ и куда уходят правки».
Страница вашего приложения. Редактора в ней нет — есть карточка документа и место, куда он встанет.
<!doctype html><!-- Страница вашего приложения. Редактора здесь нет: есть карточка документа, какая у вас уже наверняка есть, и место, куда он встанет.--><html lang="ru"> <head> <meta charset="utf-8" /> <title>Договор №42 — документы</title> <link rel="stylesheet" href="/src/styles.css" /> </head> <body> <header class="bar"> <span class="brand">Документы</span> <span class="who" data-who>…</span> </header>
<main class="card"> <h1>Договор №42</h1> <p class="meta">docx · загружен 14 марта · <span data-state>только просмотр</span></p> <button type="button" data-edit>Редактировать</button>
<!-- Сюда встанет рамка редактора. До нажатия её нет: пока документ не открывают, поднимать кабинку незачем. --> <div class="stage" data-stage hidden></div> </main>
<script type="module" src="/src/embed.js"></script> </body></html>Всё подключение целиком. Рамка на адрес вашего домена; никакого модуля в браузер не загружается.
/** * Подключение редактора — весь клиентский код примера. * * Редактор открывается рамкой на адрес `/fastdoc/?doc=<идентификатор>`. * Никакого модуля в браузер не загружается: документ считает ядро на * сервере, а рамка показывает его страницу. * * Событий наружу редактор не шлёт, и слушать здесь нечего. Связь с вашим * приложением идёт другой дорогой: правки редактор отправляет на ваш же * сервер, в «файл по идентификатору». Что произошло с документом, вы * узнаёте оттуда, а не из браузера. */const DOC_ID = "contract-42";
const stage = document.querySelector("[data-stage]");const edit = document.querySelector("[data-edit]");const state = document.querySelector("[data-state]");const who = document.querySelector("[data-who]");
/* Имя пользователя берём из своего же API — того самого, которое потом спросит редактор. Одна личность на всё приложение. */fetch("/v1/me") .then((res) => (res.ok ? res.json() : null)) .then((user) => { who.textContent = user ? user.name : "не выполнен вход"; }) .catch(() => { who.textContent = "не выполнен вход"; });
edit.addEventListener("click", () => { const frame = document.createElement("iframe"); frame.src = `/fastdoc/?doc=${encodeURIComponent(DOC_ID)}`; frame.title = "Редактор документа"; /* Буфер обмена нужен явным разрешением: без него копирование между документом и остальным браузером работать не будет. */ frame.allow = "clipboard-read; clipboard-write"; stage.replaceChildren(frame); stage.hidden = false; edit.disabled = true; state.textContent = "открыт на правку";});Оформление вашей страницы. Редактор его не касается: внутри рамки свой документ, снаружи ваш интерфейс.
/* Оформление вашей страницы. Редактор его не касается: внутри рамки свой документ, снаружи — ваш интерфейс. */:root { color-scheme: light; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;}
body { margin: 0; background: #f6f7f9; color: #1a1d21;}
.bar { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.25rem; background: #fff; border-bottom: 1px solid #e3e6ea;}
.brand { font-weight: 600;}
.who { color: #6b7280; font-size: 0.875rem;}
.card { max-width: 64rem; margin: 1.5rem auto; padding: 1.25rem; background: #fff; border: 1px solid #e3e6ea; border-radius: 0.5rem;}
h1 { margin: 0 0 0.25rem; font-size: 1.25rem;}
.meta { margin: 0 0 1rem; color: #6b7280; font-size: 0.875rem;}
button { padding: 0.5rem 1rem; border: 0; border-radius: 0.375rem; background: #2563eb; color: #fff; font: inherit; cursor: pointer;}
button[disabled] { background: #9aa4b2; cursor: default;}
/* Рамке нужна заданная высота: редактор занимает её целиком, а сам её не назначает — снаружи виднее, сколько места ему отдать. */.stage { margin-top: 1rem; height: 75vh; border: 1px solid #e3e6ea; border-radius: 0.375rem; overflow: hidden;}
.stage iframe { display: block; width: 100%; height: 100%; border: 0;}Ваш сервер: кто пришёл, файл по идентификатору, приём правок. Здесь же проброс на веб-хост A4 — в рабочем хозяйстве это правило nginx.
/** * Ваш сервер: два адреса, которых редактору достаточно, и приём правок. * * Редактор своих учётных записей не имеет и своего хранилища тоже. Кто * пришёл и какой файл ему отдать — решаете вы, этими двумя ответами: * * GET /v1/me — кто пришёл * GET /v1/files/{id}/content — сам документ, если этому человеку можно * POST /v1/files/{id}/replace — сохранение правок обратно к вам * * Если у вас есть личный кабинет и файлы, оба первых адреса уже написаны. * * Здесь же для полноты картины лежит и то, что в вашем хозяйстве обычно * делает nginx: раздача страницы и проброс `/fastdoc/` на веб-хост A4. * Отдельным сервером это или правилом в вашем — дело вкуса; важно, что * редактор и ваш API отвечают на одном домене и с теми же куками. */import { createServer } from "node:http";import { readFile, writeFile } from "node:fs/promises";import { extname, join, normalize } from "node:path";
const arg = (name, fallback) => { const at = process.argv.indexOf(name); return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : fallback;};
const docPath = arg("--doc", "./document.docx");const port = Number(arg("--port", "8080"));/* Адрес веб-хоста A4 — того самого процесса, который поднимает кабинки. */const webhost = arg("--webhost", "http://127.0.0.1:8090");const root = new URL("../", import.meta.url).pathname;
const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8",};const DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** * Правки приходят как `multipart/form-data` — одной частью с именем `file`. * * Тело обязательно РАЗБИРАТЬ. Если записать в документ сырое тело запроса, * к началу файла приклеятся служебные строки формы, и .docx перестанет * открываться — молча, без ошибки в журнале. Признак этой беды при * распаковке: «extra bytes at beginning». */const filePart = (body, contentType) => { const boundary = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType ?? ""); if (!boundary) return null; const mark = Buffer.from(`--${boundary[1] ?? boundary[2]}`);
let at = body.indexOf(mark); while (at !== -1) { const headEnd = body.indexOf("\r\n\r\n", at); if (headEnd === -1) return null; const head = body.subarray(at, headEnd).toString("utf8"); const next = body.indexOf(mark, headEnd); if (next === -1) return null; /* Перед следующей границей стоит CRLF, который к содержимому не относится: он разделитель, а не байт документа. */ if (/name="file"/i.test(head)) return body.subarray(headEnd + 4, next - 2); at = next; } return null;};
const readBody = (req) => new Promise((resolve, reject) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); });
const server = createServer(async (req, res) => { const url = new URL(req.url, `http://127.0.0.1:${port}`);
/* Кто пришёл. В настоящем приложении здесь ваша сессия; отказ отвечайте кодом 401 или 403 — редактор покажет его человеку словами. */ if (url.pathname === "/v1/me") { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ id: "u-1", name: "Анна Петрова" })); return; }
/* Файл по идентификатору. Право проверяете вы: не отдали файл — редактор не откроется, и это правильный ответ. */ if (url.pathname.endsWith("/content") && req.method === "GET") { res.writeHead(200, { "content-type": DOCX, /* Имя файла отсюда попадёт в заголовок вкладки и в имя копии, если документ понадобится второму человеку. */ "content-disposition": "attachment; filename*=UTF-8''" + encodeURIComponent("Договор №42.docx"), }); res.end(await readFile(docPath)); return; }
/* Правки обратно. Здесь — ваше хранилище: версия, аудит, уведомления. */ if (url.pathname.endsWith("/replace") && req.method === "POST") { const body = await readBody(req); const bytes = filePart(body, req.headers["content-type"]); if (!bytes) { res.writeHead(400, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "ожидалась часть с именем file" })); return; } await writeFile(docPath, bytes); console.log(`сохранено ${bytes.length} байт`); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; }
/* Проброс на веб-хост A4. Куки идут насквозь: по ним он и спросит у вас, кто пришёл. В рабочем хозяйстве это правило nginx. */ if (url.pathname === "/fastdoc/" || url.pathname.startsWith("/fastdoc/")) { const target = new URL(url.pathname + url.search, webhost); const upstream = await fetch(target, { method: req.method, headers: { ...req.headers, host: new URL(webhost).host }, body: req.method === "GET" || req.method === "HEAD" ? undefined : req, redirect: "manual", duplex: "half", }).catch(() => null); if (!upstream) { res.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); res.end("веб-хост A4 не отвечает"); return; } res.writeHead(upstream.status, Object.fromEntries(upstream.headers)); res.end(Buffer.from(await upstream.arrayBuffer())); return; }
/* Ваша страница и её файлы. */ const name = url.pathname === "/" ? "/index.html" : url.pathname; const path = join(root, normalize(name).replace(/^(\.\.[/\\])+/, "")); const body = await readFile(path).catch(() => null); if (!body) { res.writeHead(404); res.end(); return; } res.writeHead(200, { "content-type": TYPES[extname(path)] ?? "application/octet-stream" }); res.end(body);});
server.listen(port, () => { console.log(`пример открыт: http://127.0.0.1:${port}/`); console.log(`редактор берётся с ${webhost}`);});На чём легко ошибиться
Заголовок раздела «На чём легко ошибиться»Правки приходят к вам как multipart/form-data — одной частью с именем
file. Тело обязательно разбирать. Если записать в документ сырое тело
запроса, к началу файла приклеятся служебные строки формы, и документ
перестанет открываться — молча, без ошибки в журнале. Признак этой беды при
распаковке: «extra bytes at beginning».
На этом попадались и мы сами, поэтому разбор в примере выписан целиком, а не сведён к вызову библиотеки.
Чем этот пример отличается от рабочего встраивания
Заголовок раздела «Чем этот пример отличается от рабочего встраивания»| Что | В этом примере | В вашем приложении |
|---|---|---|
| Кто пользователь | Гость: личность одна на всех посетителей | Ваш вошедший пользователь |
| Откуда документ | Один и тот же демонстрационный файл | Ваш файл по идентификатору |
| Куда идут правки | Никуда: сохранение принимается и не записывается | В ваше хранилище, вашим же API |
| Права | Не проверяются | Проверяете вы, в том числе повторно во время работы |
Проброс /fastdoc/ |
Сделан тем же процессом, для краткости | Обычно правило nginx |
Всё остальное совпадает: тот же редактор, то же ядро, тот же способ встраивания.
- Как подключить у себя, с тремя шагами и обязательными условиями — Редактор в браузере.
- Где проходят границы ответственности — Ответственность и безопасность.
- Другие способы разместить A4 — Платформы и размещение.