@alfacapital-tech/c4builder 0.3.0-rc.1 → 0.3.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,51 +1,92 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
- import http from 'node:http';
5
- import https from 'node:https';
6
5
  import crypto from 'node:crypto';
7
6
  import fsextra from 'fs-extra';
8
7
  import { spawn } from 'node:child_process';
8
+ import { createRequire } from 'node:module';
9
9
  import { writeFile, VENDORED_JAR } from "../../util/utils.js";
10
+ import { httpGetBuffer } from "../../util/http.js";
10
11
  // D2-бэкенд: только статические хелперы (парсинг импортов) грузятся сразу; сам
11
12
  // движок @terrastruct/d2 тянется лениво внутри renderD2/teardownD2.
12
13
  import { renderD2, foldD2Imports } from "./d2renderer.js";
13
14
  import { resolveJava } from "./jre.js";
14
15
  // PNG-выход: SVG обоих движков растеризуется resvg (ленивая загрузка внутри модуля).
15
16
  import { rasterizeSvgToPng } from "./pngraster.js";
17
+ // Шрифт-пин общий с resvg-растеризатором (одна точка правды, см. fonts.ts).
18
+ import { FONTS_DIR, DEFAULT_FONT_NAME } from "./fonts.js";
16
19
  import { VENDOR_DIR } from "../../util/paths.js";
17
20
  import { foldIncludes, diagramOutputFormat } from "../scan/tree.js";
18
- // Вендорный шрифт: ширина текста в SVG считается из AWT-метрик, поэтому
19
- // шрифт пинуется, чтобы рендер не зависел от того, что установлено на машине.
20
- const FONTS_DIR = path.join(VENDOR_DIR, 'fonts');
21
- const DEFAULT_FONT_NAME = 'Liberation Sans';
21
+ // Таймаут рендера ОДНОЙ диаграммы: зависший JVM (циклы Smetana-layout) не эмитит
22
+ // ни 'close', ни 'error' — без таймаута await виснет вечно, а в watch-режиме
23
+ // isBuilding остаётся true навсегда. По таймауту процесс убивается (SIGTERM→SIGKILL).
24
+ const RENDER_TIMEOUT_MS = 120_000;
25
+ const SIGKILL_GRACE_MS = 5_000;
26
+ // Размер пула PlantUML-рендера. Каждая задача — отдельный процесс java (свой JVM,
27
+ // свои сотни МБ heap), поэтому пул НЕ равен числу ядер: дефолт — cpus-1, но не более 4.
28
+ // Переопределяется env C4BUILDER_RENDER_CONCURRENCY (целое ≥1); мусор — молча дефолт.
29
+ const renderConcurrency = () => {
30
+ const raw = process.env.C4BUILDER_RENDER_CONCURRENCY;
31
+ if (raw !== undefined) {
32
+ const n = Number(raw);
33
+ if (Number.isInteger(n) && n >= 1)
34
+ return n;
35
+ }
36
+ return Math.max(1, Math.min(4, os.cpus().length - 1));
37
+ };
38
+ // Пул ограниченной конкурентности: гоняет tasks не более `concurrency` одновременно.
39
+ // Порядок вызова задач сохраняется (worker'ы разбирают общий индекс next), результат —
40
+ // не собираем (каждая задача пишет свой файл сама). При первой ошибке новые задачи не
41
+ // стартуют, но УЖЕ запущенные дожидаются (finally у Promise.all в воркере) — чтобы не
42
+ // осталось висящих JVM, — после чего пробрасывается первая пойманная ошибка.
43
+ const runPool = async (tasks, concurrency) => {
44
+ let next = 0;
45
+ let firstError;
46
+ const worker = async () => {
47
+ while (next < tasks.length && firstError === undefined) {
48
+ const task = tasks[next++];
49
+ try {
50
+ await task();
51
+ }
52
+ catch (err) {
53
+ if (firstError === undefined)
54
+ firstError = err;
55
+ }
56
+ }
57
+ };
58
+ const width = Math.max(1, Math.min(concurrency, tasks.length));
59
+ const workers = [];
60
+ for (let i = 0; i < width; i++)
61
+ workers.push(worker());
62
+ await Promise.all(workers);
63
+ if (firstError !== undefined)
64
+ throw firstError;
65
+ };
66
+ // Версия движка @terrastruct/d2 для чексуммы кэша (апгрейд движка → перерендер).
67
+ // Читаем package.json лениво и один раз: pure-PlantUML сборки его не трогают.
68
+ let d2VersionCache = null;
69
+ const d2EngineVersion = () => {
70
+ if (d2VersionCache !== null)
71
+ return d2VersionCache;
72
+ try {
73
+ const req = createRequire(import.meta.url);
74
+ d2VersionCache = req('@terrastruct/d2/package.json').version;
75
+ }
76
+ catch {
77
+ d2VersionCache = 'unknown';
78
+ }
79
+ return d2VersionCache;
80
+ };
22
81
  export const getMime = (format) => {
23
82
  if (format === 'svg')
24
83
  return `image/svg+xml`;
25
84
  return `image/${format}`;
26
85
  };
27
- export const httpGet = async (url) => {
28
- // return new pending promise
29
- return new Promise((resolve, reject) => {
30
- // select http or https module, depending on reqested url
31
- const lib = url.startsWith('https') ? https : http;
32
- const request = lib.get(url, (response) => {
33
- const status = response.statusCode; // ответ всегда со статусом
34
- // handle http errors
35
- if (status < 200 || status > 299) {
36
- reject(new Error(`Failed to load page ${url}, status code: ${status}`));
37
- }
38
- // temporary data holder
39
- const body = [];
40
- // on every content chunk, push it to the data array
41
- response.on('data', (chunk) => body.push(chunk));
42
- // we are done, resolve promise with those joined chunks
43
- response.on('end', () => resolve(Buffer.concat(body).toString('base64')));
44
- });
45
- // handle connection errors of the request
46
- request.on('error', (err) => reject(err));
47
- });
48
- };
86
+ // Загрузка удалённо отрендеренной картинки (embed-ветка онлайн-рендера PlantUML) в
87
+ // base64. Редиректы и таймауты берём из общего util/http: прежде здесь был голый GET
88
+ // без обоих — 302 от PlantUML-сервера ронял embed-сборку, а зависший сокет вешал её.
89
+ export const httpGet = async (url) => (await httpGetBuffer(url)).toString('base64');
49
90
  // Прямой вызов PlantUML: layout считает встроенный Java-движок Smetana
50
91
  // (`-Playout=smetana`), внешний graphviz/dot не нужен. Диаграмма подаётся в stdin
51
92
  // (`-pipe`), результат читается из stdout — имя выходного файла задаёт c4builder,
@@ -53,6 +94,14 @@ export const httpGet = async (url) => {
53
94
  // ditaa рендерит собственный движок (layout не участвует), а `-Playout=smetana`
54
95
  // на нём меняет размер холста — поэтому для ditaa флаг не передаётся (выход
55
96
  // байт-в-байт совпадает с историческим).
97
+ //
98
+ // Оба имени шрифта пиним явно. `-SdefaultFontName` покрывает текст, а бэджи классов
99
+ // (буквы «C»/«A»/«I» в кружках) PlantUML рисует КРИВЫМИ отдельного шрифта
100
+ // CircledCharacterFontName: без пина это логический шрифт JVM, который резолвит
101
+ // системный fontconfig (Ubuntu → DejaVu/TrueType, Arch → Nimbus/CFF) — контуры
102
+ // расходятся между ОС. С пином шрифт берётся из vendor/fonts (JRE своих не несёт,
103
+ // freetype у Temurin бандлед), и SVG получается одинаковым везде.
104
+ // ditaa этим не лечится: его движок берёт AWT-шрифт мимо обеих опций.
56
105
  export const renderDiagram = (content, { javaBin, jarPath, includePath, format, charset, isDitaa }) => new Promise((resolve, reject) => {
57
106
  const argv = [
58
107
  '-Djava.awt.headless=true',
@@ -62,6 +111,7 @@ export const renderDiagram = (content, { javaBin, jarPath, includePath, format,
62
111
  jarPath,
63
112
  ...(isDitaa ? [] : ['-Playout=smetana']),
64
113
  `-SdefaultFontName=${DEFAULT_FONT_NAME}`,
114
+ `-SCircledCharacterFontName=${DEFAULT_FONT_NAME}`,
65
115
  '-charset',
66
116
  charset,
67
117
  `-t${format}`,
@@ -76,17 +126,49 @@ export const renderDiagram = (content, { javaBin, jarPath, includePath, format,
76
126
  if (!stdin || !childOut || !childErr) {
77
127
  return reject(new Error('PlantUML: не удалось открыть stdio-потоки процесса java'));
78
128
  }
129
+ // Smetana печатает диагностический шум (UNSURE_ABOUT…) — это не ошибка
130
+ // рендера, пользователю не показываем; остальной stderr пробрасываем.
131
+ const cleanStderr = () => Buffer.concat(stderr)
132
+ .toString('utf8')
133
+ .split('\n')
134
+ .filter((line) => line.trim() && !/UNSURE_ABOUT/.test(line))
135
+ .join('\n');
136
+ let settled = false;
137
+ let sigkillTimer;
138
+ const timer = setTimeout(() => {
139
+ if (settled)
140
+ return;
141
+ settled = true;
142
+ // JVM завис: мягко SIGTERM, затем жёстко SIGKILL, если не среагировал.
143
+ child.kill('SIGTERM');
144
+ sigkillTimer = setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS);
145
+ sigkillTimer.unref();
146
+ const tail = cleanStderr();
147
+ reject(new Error(`PlantUML не завершился за ${RENDER_TIMEOUT_MS / 1000} c и был прерван` +
148
+ (tail ? `\n${tail}` : '')));
149
+ }, RENDER_TIMEOUT_MS);
150
+ timer.unref();
151
+ const clearTimers = () => {
152
+ clearTimeout(timer);
153
+ if (sigkillTimer)
154
+ clearTimeout(sigkillTimer);
155
+ };
79
156
  childOut.on('data', (chunk) => stdout.push(chunk));
80
157
  childErr.on('data', (chunk) => stderr.push(chunk));
81
- child.on('error', reject); // java не найдена и пр.
158
+ child.on('error', (err) => {
159
+ // java не найдена и пр.
160
+ clearTimers();
161
+ if (settled)
162
+ return;
163
+ settled = true;
164
+ reject(err);
165
+ });
82
166
  child.on('close', (code) => {
83
- // Smetana печатает диагностический шум (UNSURE_ABOUT…) — это не ошибка
84
- // рендера, пользователю не показываем; остальной stderr пробрасываем.
85
- const errText = Buffer.concat(stderr)
86
- .toString('utf8')
87
- .split('\n')
88
- .filter((line) => line.trim() && !/UNSURE_ABOUT/.test(line))
89
- .join('\n');
167
+ clearTimers(); // гасим таймеры даже если сработал timeout (не убиваем чужой PID)
168
+ if (settled)
169
+ return;
170
+ settled = true;
171
+ const errText = cleanStderr();
90
172
  if (code !== 0) {
91
173
  return reject(new Error(`PlantUML завершился с кодом ${code}\n${errText}`));
92
174
  }
@@ -102,9 +184,14 @@ export const renderDiagram = (content, { javaBin, jarPath, includePath, format,
102
184
  // по чексумме. Его lifecycle (создание/удаление) держит оркестратор build(); сюда
103
185
  // путь приходит явным параметром, а не пересчитывается из общей константы.
104
186
  export const generateImages = async (tree, options, onImageGenerated, cacheConf, bkFolderName) => {
105
- // Get the old checksums (from last run) of all PUML-files
106
- const oldChecksums = cacheConf.get('checksums') || [];
107
- const newChecksums = [];
187
+ // Чексуммы прошлого прогона — в Set: попадание проверяется O(1) на диаграмму
188
+ // (было O(N) find → O(N²) на всё дерево).
189
+ const oldChecksums = new Set(cacheConf.get('checksums') || []);
190
+ // Каждой диаграмме — запись в порядке обхода дерева; ok выставляется задачей при
191
+ // УСПЕХЕ (рендер или восстановление из бэкапа). В кеш пишем только успешные: иначе
192
+ // упавший прогон терял бы чексуммы уже отрендеренных → их полный перерендер на
193
+ // следующем запуске (см. persist в finally ниже).
194
+ const checksumEntries = [];
108
195
  let totalImages = 0;
109
196
  let processedImages = 0;
110
197
  // Рендерим единственным вендорным JAR — выбора версии больше нет. Легаси-ключ
@@ -117,17 +204,37 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
117
204
  console.log(chalk.yellow(`Выбор версии PlantUML удалён — сборка идёт вендорным JAR ${VENDORED_JAR.version}. ` +
118
205
  `Ключ plantumlVersion: "${pinned}" в .c4builder можно убрать.`));
119
206
  }
120
- // JRE резолвится ЛЕНИВО и один раз за сборку: только если в дереве есть хотя бы одна
121
- // PlantUML-диаграмма. Проект целиком на D2 java не трогает (скачивание не инициируется).
122
- const needsJava = tree.some((item) => item.diagrams.some((d) => d.engine === 'plantuml'));
123
- let javaBin = null;
124
- if (needsJava) {
125
- javaBin = (await resolveJava({ log: (m) => console.log(chalk.gray(m)) })).path;
126
- }
207
+ // JRE резолвится ЛЕНИВО и один раз за сборку: только когда встретилась PlantUML-
208
+ // диаграмма, которую РЕАЛЬНО надо рендерить (кэш не сработал — restore-путь ниже
209
+ // делает continue до создания render-задачи). Проект целиком на D2 либо полностью
210
+ // восстановленный из бэкапа java не трогает — важно для офлайн-пересборки готового
211
+ // проекта на машине без установленной java (иначе резолв инициировал бы скачивание).
212
+ let javaPromise = null;
213
+ const getJava = () => {
214
+ if (!javaPromise) {
215
+ javaPromise = resolveJava({ log: (m) => console.log(chalk.gray(m)) }).then((r) => r.path);
216
+ }
217
+ return javaPromise;
218
+ };
127
219
  for (const item of tree) {
128
220
  totalImages += item.diagrams.length;
129
221
  }
130
- const taskList = [];
222
+ // Прогресс инкрементируем по ЗАВЕРШЕНИИ каждой задачи. Порядок завершения при
223
+ // конкурентности недетерминирован (сообщения `processed X/Y` могут идти вразнобой),
224
+ // но X монотонно растёт и финал = totalImages. Инкремент атомарен (один event-loop).
225
+ const withProgress = (task) => async () => {
226
+ await task();
227
+ processedImages++;
228
+ if (onImageGenerated)
229
+ onImageGenerated(processedImages, totalImages);
230
+ };
231
+ // D2-задачи гоняем отдельной очередью с конкурентностью 1: @terrastruct/d2 держит
232
+ // ЕДИНСТВЕННЫЙ worker с одним currentResolve/currentReject без корреляции запросов —
233
+ // два параллельных renderD2 затирают колбэк друг друга (промис виснет / результаты
234
+ // перепутываются). Движок всё равно однопоточный, так что сериализация D2 бесплатна.
235
+ // PlantUML — отдельные процессы java, их гоняем пулом renderConcurrency().
236
+ const d2Tasks = [];
237
+ const otherTasks = [];
131
238
  for (const item of tree) {
132
239
  for (const diagram of item.diagrams) {
133
240
  // Чексумма = контент диаграммы + свёрнутый граф её зависимостей, чтобы
@@ -140,59 +247,99 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
140
247
  const includes = entryPath !== null
141
248
  ? foldD2Imports(entryPath)
142
249
  : foldIncludes(body, item.dir, item.dir, new Set());
250
+ const outFormat = diagramOutputFormat(diagram, options);
251
+ // renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
252
+ // импортов. Без него смена layout (D2) или charset (PlantUML) не меняла
253
+ // чексумму → из бэкапа копировалась старая картинка (новые параметры молча
254
+ // игнорировались, кэш чистился только через --reset). Формат/движок/версия
255
+ // движка тоже в ключе: их смена обязана приводить к перерендеру.
256
+ const renderKey = entryPath !== null
257
+ ? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
258
+ : `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}\0font=${DEFAULT_FONT_NAME}`;
143
259
  const cksum = crypto
144
260
  .createHash('sha256')
145
- .update(body + includes, 'utf-8')
261
+ .update(body + includes + renderKey, 'utf-8')
146
262
  .digest('hex');
147
- const outName = `${path.parse(diagram.dir).name}.${diagramOutputFormat(diagram, options)}`;
263
+ // Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
264
+ // порядок массива не зависит от того, кто из воркеров финиширует раньше —
265
+ // .c4builder.cache детерминирован (важно для сравнения кэша между сборками).
266
+ const cksumEntry = { cksum, ok: false };
267
+ checksumEntries.push(cksumEntry);
268
+ const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
148
269
  // path to backup image file
149
270
  const bkFilePath = path.join(bkFolderName, item.dir.replace(options.ROOT_FOLDER, ''), outName);
150
271
  // path to image in dist folder
151
272
  const filePath = path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), outName);
152
273
  // if checksum exists (diagram untouched) and file/image exists - copy image back from backup folder
153
- if (oldChecksums.find((x) => x === cksum) && (await fs.existsSync(bkFilePath))) {
154
- await fsextra.copyFileSync(bkFilePath, filePath);
274
+ if (oldChecksums.has(cksum) && fs.existsSync(bkFilePath)) {
275
+ // Восстановление из бэкапа — без рендера; JVM/worker не задействованы,
276
+ // поэтому кладём в общий пул (kind не важен).
277
+ otherTasks.push(withProgress(async () => {
278
+ fsextra.copyFileSync(bkFilePath, filePath);
279
+ cksumEntry.ok = true;
280
+ }));
281
+ continue;
155
282
  }
156
- else {
157
- const outFormat = diagramOutputFormat(diagram, options);
158
- // PNG-выход не-ditaa диаграмм — растеризацией SVG (resvg), а не нативным
159
- // движком: единый детерминированный PNG для PlantUML и D2. ditaa остаётся
160
- // нативным PlantUML-PNG (у него нет SVG-представления) — не растеризуем.
161
- const needsRaster = outFormat === 'png' && !diagram.isDitaa;
283
+ // PNG-выход не-ditaa диаграмм — растеризацией SVG (resvg), а не нативным
284
+ // движком: единый детерминированный PNG для PlantUML и D2. ditaa остаётся
285
+ // нативным PlantUML-PNG (у него нет SVG-представления) — не растеризуем.
286
+ const needsRaster = outFormat === 'png' && !diagram.isDitaa;
287
+ const diagramContent = diagram.content;
288
+ const includePath = item.dir;
289
+ const isDitaa = diagram.isDitaa;
290
+ const task = withProgress(async () => {
162
291
  // render diagram to image: D2 через WASM, PlantUML — прямым вызовом java.
163
292
  // Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
164
293
  let rendered;
165
- if (entryPath !== null) {
166
- rendered = renderD2(entryPath, { layout: options.D2_LAYOUT });
294
+ try {
295
+ if (entryPath !== null) {
296
+ rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT });
297
+ }
298
+ else {
299
+ // Резолв JRE отложен до первого реального PlantUML-рендера (эта
300
+ // ветка исполняется лишь при промахе кэша). getJava мемоизирует.
301
+ const javaBin = await getJava();
302
+ rendered = await renderDiagram(diagramContent, {
303
+ javaBin,
304
+ jarPath,
305
+ includePath,
306
+ format: needsRaster ? 'svg' : outFormat,
307
+ charset: options.CHARSET,
308
+ isDitaa
309
+ });
310
+ }
311
+ const image = needsRaster ? await rasterizeSvgToPng(rendered) : rendered;
312
+ await writeFile(filePath, image);
313
+ cksumEntry.ok = true;
167
314
  }
168
- else {
169
- // needsJava гарантирует резолв для plantuml-ветки; guard — нарратив для типов.
170
- if (!javaBin)
171
- throw new Error('PlantUML: JRE не резолвнут для plantuml-диаграммы');
172
- rendered = renderDiagram(diagram.content, {
173
- javaBin,
174
- jarPath,
175
- includePath: item.dir,
176
- format: needsRaster ? 'svg' : outFormat,
177
- charset: options.CHARSET,
178
- isDitaa: diagram.isDitaa
179
- });
315
+ catch (err) {
316
+ // Имя диаграммы в ошибку: renderDiagram/renderD2 сами его не знают.
317
+ throw new Error(`Диаграмма "${outName}": ${err.message || err}`);
180
318
  }
181
- const render = rendered
182
- .then((image) => (needsRaster ? rasterizeSvgToPng(image) : image))
183
- .then((image) => writeFile(filePath, image));
184
- taskList.push(render);
185
- }
186
- const taskPromises = Promise.all(taskList).then(() => {
187
- processedImages++;
188
- if (onImageGenerated)
189
- onImageGenerated(processedImages, totalImages);
190
319
  });
191
- await taskPromises;
192
- // Add diagram checksum
193
- newChecksums.push(cksum);
320
+ if (entryPath !== null)
321
+ d2Tasks.push(task);
322
+ else
323
+ otherTasks.push(task);
194
324
  }
195
325
  }
196
- // store all puml checksums
197
- cacheConf.set('checksums', newChecksums);
326
+ // PlantUML-пул и D2-очередь работают независимо (процессы java vs WASM-worker) и
327
+ // параллельно друг другу. allSettled — чтобы падение одной очереди не оставляло
328
+ // вторую как floating promise с висящими JVM: обе доосушаются, затем бросаем первую
329
+ // ошибку (приоритет — PlantUML-пул, порядок между очередями не значим).
330
+ try {
331
+ const results = await Promise.allSettled([
332
+ runPool(otherTasks, renderConcurrency()),
333
+ runPool(d2Tasks, 1)
334
+ ]);
335
+ const failed = results.find((r) => r.status === 'rejected');
336
+ if (failed && failed.status === 'rejected')
337
+ throw failed.reason;
338
+ }
339
+ finally {
340
+ // Персистим чексуммы даже при частичном падении: успешно обработанные диаграммы
341
+ // не перерендерятся на следующем прогоне. Вместе с сохранением dist_bk при ошибке
342
+ // (build.ts) повторный прогон после сбоя чинит только упавшую диаграмму.
343
+ cacheConf.set('checksums', checksumEntries.filter((e) => e.ok).map((e) => e.cksum));
344
+ }
198
345
  };
@@ -0,0 +1,8 @@
1
+ import path from 'node:path';
2
+ import { VENDOR_DIR } from "../../util/paths.js";
3
+ // Единая точка детерминизма рендера текста. Вендорный шрифт пинуется в ОБА движка:
4
+ // JVM/PlantUML (`-Dsun.java2d.fontpath`, `-SdefaultFontName`) и resvg/PNG
5
+ // (`fontDirs`, `defaultFontFamily`). Значения обязаны совпадать — иначе метрики
6
+ // текста разъедутся и PNG-golden покраснеют. НЕ менять без пересбора эталонов.
7
+ export const FONTS_DIR = path.join(VENDOR_DIR, 'fonts');
8
+ export const DEFAULT_FONT_NAME = 'Liberation Sans';