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

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.
@@ -8,10 +8,10 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
- import https from 'node:https';
12
11
  import crypto from 'node:crypto';
13
12
  import { createRequire } from 'node:module';
14
13
  import { spawnSync } from 'node:child_process';
14
+ import { httpGetStream, httpGetJson } from "../../util/http.js";
15
15
  // yauzl/tar грузятся лениво (только при распаковке скачанного JRE) — сохраняем это
16
16
  // синхронным require через createRequire, а не тянем их на импорте модуля.
17
17
  const require = createRequire(import.meta.url);
@@ -115,50 +115,82 @@ const findJavaUnder = (root) => {
115
115
  }
116
116
  return null;
117
117
  };
118
- // (2) кеш: валиден только при наличии исполняемого bin/java.
119
- const cachedJava = () => {
120
- const bin = findJavaUnder(jreCacheDir());
121
- return bin ? { path: bin, source: 'cache' } : null;
118
+ // Версия схемы кеша JRE: инкрементируется при смене раскладки каталога или формата
119
+ // маркера. Внешние кеши (ключ actions/cache в .github/workflows/ci.yml) включают её,
120
+ // иначе кеш, снятый старой схемой, восстанавливался бы вечно: cachedJava признал бы
121
+ // его невалидным, а actions/cache при попадании в ключ не пересохраняет каталог.
122
+ const JRE_CACHE_SCHEMA = 1;
123
+ // Маркер завершённости распаковки: кладётся ПОСЛЕДНИМ в staging и едет в кеш вместе
124
+ // с ним. cachedJava доверяет кешу только при его наличии — иначе прерванная (Ctrl+C)
125
+ // или гоночная распаковка (bin/java уже есть, lib/modules ещё нет) навсегда выдавалась
126
+ // бы за валидный JRE. Проверка дешёвая: существование файла, без запуска JVM.
127
+ const MARKER_NAME = '.c4builder-jre-ready.json';
128
+ const markerPath = (root) => path.join(root, MARKER_NAME);
129
+ // Валиден ли распакованный JRE в каталоге root: маркер завершённости той же схемы +
130
+ // НАЙДЕННЫЙ и ЗАПУСКАЕМЫЙ bin/java. Запускаемость проверяем так же, как detectSystemJava
131
+ // (javaMajor): маркер мог остаться от оборванной мимо него распаковки или частичного
132
+ // копирования кеша (битые lib/*), и тогда «валидный» по маркеру бинарь всё равно не
133
+ // стартует. Возвращает путь к бинарю или null.
134
+ const installedJavaAt = (root) => {
135
+ let marker;
136
+ try {
137
+ marker = JSON.parse(fs.readFileSync(markerPath(root), 'utf-8'));
138
+ }
139
+ catch {
140
+ return null; // маркера нет или он битый — распаковка не завершилась
141
+ }
142
+ if (marker.schema !== JRE_CACHE_SCHEMA)
143
+ return null; // кеш старой раскладки
144
+ const bin = findJavaUnder(root);
145
+ if (!bin)
146
+ return null;
147
+ return javaMajor(bin) !== null ? bin : null;
122
148
  };
123
- // --- HTTP с обработкой редиректов (assets-эндпоинт может 307-редиректить на github) ---
124
- const httpGet = (url) => new Promise((resolve, reject) => {
125
- https
126
- .get(url, { headers: { 'User-Agent': 'c4builder-jre-resolver' } }, (res) => {
127
- const statusCode = res.statusCode; // ответ всегда со статусом
128
- const { headers } = res;
129
- if (statusCode >= 300 && statusCode < 400 && headers.location) {
130
- res.resume();
131
- return resolve(httpGet(new URL(headers.location, url).toString()));
149
+ // (2) кеш: валиден при маркере той же схемы И запускаемом bin/java. Битый/неполный
150
+ // каталог сносим, чтобы resolveJava ушёл на перекачку, а не залипал на нём (downloadJre
151
+ // пишет в свой staging, поэтому невалидный dir — это заведомо остаток, а не чужая
152
+ // установка в процессе).
153
+ const cachedJava = () => {
154
+ const root = jreCacheDir();
155
+ const bin = installedJavaAt(root);
156
+ if (bin)
157
+ return { path: bin, source: 'cache' };
158
+ if (fs.existsSync(root)) {
159
+ try {
160
+ fs.rmSync(root, { recursive: true, force: true });
132
161
  }
133
- if (statusCode !== 200) {
134
- res.resume();
135
- return reject(new Error(`HTTP ${statusCode} для ${url}`));
162
+ catch {
163
+ /* не удалось снести — downloadJre всё равно перезапишет каталог */
136
164
  }
137
- resolve(res);
138
- })
139
- .on('error', reject);
140
- });
141
- const httpGetJson = async (url) => {
142
- const res = await httpGet(url);
143
- const chunks = [];
144
- for await (const c of res)
145
- chunks.push(c);
146
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
165
+ }
166
+ return null;
147
167
  };
148
- // Adoptium assets API отдаёт ссылку и sha256 одним запросом.
168
+ // HTTP-запросы (редиректы + таймауты) — в общем util/http. Здесь только User-Agent
169
+ // резолвера: Adoptium assets-эндпоинт 307-редиректит на github, приём архива ~50 МБ.
170
+ const JRE_UA = { 'User-Agent': 'c4builder-jre-resolver' };
171
+ // Adoptium assets API отдаёт ссылку и sha256 одним запросом. ВАЖНО: link и checksum
172
+ // приходят из ОДНОГО ответа API — sha256 защищает лишь от повреждения при передаче
173
+ // (усечённый/битый архив), но НЕ от компрометации самого API/ответа.
149
174
  const fetchAssetMeta = async () => {
150
175
  const url = `https://api.adoptium.net/v3/assets/latest/${TEMURIN_FEATURE}/hotspot` +
151
176
  `?image_type=jre&vendor=eclipse&os=${adoptiumOs()}&architecture=${adoptiumArch()}`;
152
- const json = await httpGetJson(url);
177
+ const json = await httpGetJson(url, { headers: JRE_UA });
153
178
  const asset = Array.isArray(json) ? json.find((a) => a.binary?.package) : null;
154
179
  if (!asset) {
155
180
  throw new Error(`Adoptium не отдал JRE-сборку под ${adoptiumOs()}/${adoptiumArch()} (Temurin ${TEMURIN_FEATURE})`);
156
181
  }
157
182
  const pkg = asset.binary.package;
183
+ if (!pkg.link)
184
+ throw new Error('Adoptium не вернул ссылку на архив JRE');
185
+ // Отсутствие checksum — ошибка, а не молчаливый пропуск проверки целостности.
186
+ if (!pkg.checksum) {
187
+ throw new Error(`Adoptium не вернул sha256 для ${pkg.name || 'архива JRE'}: ` +
188
+ 'отказ принимать архив без проверки целостности');
189
+ }
158
190
  return { link: pkg.link, sha256: pkg.checksum, name: pkg.name, release: asset.release_name };
159
191
  };
160
192
  const downloadAndVerify = async (link, expectedSha, destFile) => {
161
- const res = await httpGet(link);
193
+ const res = await httpGetStream(link, { headers: JRE_UA });
162
194
  const hash = crypto.createHash('sha256');
163
195
  await new Promise((resolve, reject) => {
164
196
  const out = fs.createWriteStream(destFile);
@@ -168,16 +200,35 @@ const downloadAndVerify = async (link, expectedSha, destFile) => {
168
200
  out.on('finish', resolve);
169
201
  res.pipe(out);
170
202
  });
171
- const actual = hash.digest('hex');
172
- if (expectedSha && actual.toLowerCase() !== expectedSha.toLowerCase()) {
203
+ const actual = hash.digest('hex').toLowerCase();
204
+ if (!expectedSha)
205
+ throw new Error('Нет ожидаемого sha256 — проверка целостности архива невозможна');
206
+ if (actual !== expectedSha.toLowerCase()) {
173
207
  throw new Error(`sha256 архива не совпал: ожидалось ${expectedSha}, получено ${actual}`);
174
208
  }
175
209
  };
210
+ // Защита от zip-slip: путь записи, разрешённый относительно destDir, обязан
211
+ // оставаться ВНУТРИ него (не `../` за пределы). Экспортируется для юнит-теста.
212
+ export const isPathInside = (destDir, entryName) => {
213
+ const root = path.resolve(destDir);
214
+ const dest = path.resolve(root, entryName);
215
+ return dest === root || dest.startsWith(root + path.sep);
216
+ };
176
217
  const extractZip = (archive, destDir) => new Promise((resolve, reject) => {
177
218
  require('yauzl').open(archive, { lazyEntries: true }, (err, zip) => {
178
219
  if (err)
179
220
  return reject(err);
180
221
  zip.on('entry', (entry) => {
222
+ // zip-slip: злонамеренная запись `../../evil` вышла бы за destDir. tar-ветка
223
+ // защищена node-tar≥6 по умолчанию, zip (yauzl) — нет, проверяем сами.
224
+ if (!isPathInside(destDir, entry.fileName)) {
225
+ return reject(new Error(`Небезопасный путь в архиве JRE (выход за каталог): ${entry.fileName}`));
226
+ }
227
+ // Симлинки пропускаем: вне доверенного Adoptium-архива симлинк мог бы
228
+ // указывать за пределы каталога. Temurin Windows-zip симлинков не содержит.
229
+ const isSymlink = ((entry.externalFileAttributes >>> 16) & 0xffff & 0o170000) === 0o120000;
230
+ if (isSymlink)
231
+ return zip.readEntry();
181
232
  const dest = path.join(destDir, entry.fileName);
182
233
  if (entry.fileName.endsWith('/')) {
183
234
  fs.mkdirSync(dest, { recursive: true });
@@ -210,25 +261,56 @@ const extractZip = (archive, destDir) => new Promise((resolve, reject) => {
210
261
  });
211
262
  });
212
263
  const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : require('tar').x({ file: archive, cwd: destDir });
213
- // (3) скачивание: assets API → sha256 (до распаковки) → распаковка в кеш.
214
- // Битую/старую установку затираем целиком перед распаковкой.
264
+ // (3) скачивание: assets API → sha256 (до распаковки) → распаковка в staging →
265
+ // атомарная замена кеша. Распаковываем НЕ в финальный каталог, а в свой staging
266
+ // (<dir>.tmp-<pid>, уникальный на процесс → параллельные сборки не топчут друг друга),
267
+ // кладём маркер и лишь затем атомарным rename заменяем кеш. Прерывание (Ctrl+C) или
268
+ // гонка не оставят финальный каталог полураспакованным.
215
269
  const downloadJre = async ({ log } = {}) => {
216
270
  const meta = await fetchAssetMeta();
217
271
  const dir = jreCacheDir();
218
272
  const isZip = adoptiumOs() === 'windows';
219
273
  const parent = path.dirname(dir);
220
274
  fs.mkdirSync(parent, { recursive: true });
221
- const tmp = path.join(parent, `.download-${process.pid}${isZip ? '.zip' : '.tar.gz'}`);
275
+ const tmpArchive = path.join(parent, `.download-${process.pid}${isZip ? '.zip' : '.tar.gz'}`);
276
+ const stageDir = `${dir}.tmp-${process.pid}`;
222
277
  try {
223
278
  if (log)
224
279
  log(`Скачивание Temurin ${TEMURIN_FEATURE} JRE (${meta.release || meta.name})…`);
225
- await downloadAndVerify(meta.link, meta.sha256, tmp);
226
- fs.rmSync(dir, { recursive: true, force: true });
227
- fs.mkdirSync(dir, { recursive: true });
228
- await extractArchive(tmp, dir, isZip);
280
+ await downloadAndVerify(meta.link, meta.sha256, tmpArchive);
281
+ fs.rmSync(stageDir, { recursive: true, force: true });
282
+ fs.mkdirSync(stageDir, { recursive: true });
283
+ await extractArchive(tmpArchive, stageDir, isZip);
284
+ if (!findJavaUnder(stageDir)) {
285
+ throw new Error('JRE распакован, но исполняемый bin/java не найден');
286
+ }
287
+ fs.writeFileSync(markerPath(stageDir), JSON.stringify({
288
+ schema: JRE_CACHE_SCHEMA,
289
+ feature: TEMURIN_FEATURE,
290
+ release: meta.release,
291
+ sha256: meta.sha256
292
+ }));
293
+ // Установка в общий кеш без межпроцессной блокировки. Не делаем rm(dir)+rename
294
+ // (окно, в котором dir исчезает у параллельной сборки): если к этому моменту в dir
295
+ // уже валидный JRE — другой процесс успел, принимаем его и выбрасываем свой stage.
296
+ // Иначе пытаемся атомарный rename; провал rename (dir возник в гонке / остался
297
+ // старый невалидный каталог) разбираем: валиден теперь → чужой, невалиден → сносим
298
+ // и ставим свой.
299
+ if (!installedJavaAt(dir)) {
300
+ try {
301
+ fs.renameSync(stageDir, dir);
302
+ }
303
+ catch {
304
+ if (!installedJavaAt(dir)) {
305
+ fs.rmSync(dir, { recursive: true, force: true });
306
+ fs.renameSync(stageDir, dir);
307
+ }
308
+ }
309
+ }
229
310
  }
230
311
  finally {
231
- fs.rmSync(tmp, { force: true });
312
+ fs.rmSync(tmpArchive, { force: true });
313
+ fs.rmSync(stageDir, { recursive: true, force: true }); // подчистить staging (no-op, если переименован)
232
314
  }
233
315
  const bin = findJavaUnder(dir);
234
316
  if (!bin)
@@ -244,22 +326,44 @@ const failureMessage = (cause) => [
244
326
  ]
245
327
  .filter(Boolean)
246
328
  .join('\n');
329
+ // Мемо результата резолва на процесс: watch-режим не платит скан PATH + spawnSync
330
+ // `java -version` (~50-300 мс) на каждый ребилд. Кешируем сам промис (как d2Promise
331
+ // в d2renderer.ts). force обходит кеш (для `jre install --force`), но успешный резолв —
332
+ // в т.ч. после force — его обновляет; провал не кешируем.
333
+ let resolvePromise = null;
247
334
  // Резолв по цепочке. { force } пропускает систему и кеш (форс-скачивание для
248
335
  // `jre install --force`). Возвращает { path, source }.
249
336
  const resolveJava = async ({ force = false, log } = {}) => {
250
- if (!force) {
251
- const sys = detectSystemJava();
252
- if (sys)
253
- return sys;
254
- const cached = cachedJava();
255
- if (cached)
256
- return cached;
257
- }
337
+ if (!force && resolvePromise)
338
+ return resolvePromise;
339
+ const run = (async () => {
340
+ if (!force) {
341
+ const sys = detectSystemJava();
342
+ if (sys)
343
+ return sys;
344
+ const cached = cachedJava();
345
+ if (cached)
346
+ return cached;
347
+ }
348
+ try {
349
+ return await downloadJre({ log });
350
+ }
351
+ catch (e) {
352
+ throw new Error(failureMessage(e));
353
+ }
354
+ })();
355
+ resolvePromise = run;
258
356
  try {
259
- return await downloadJre({ log });
357
+ return await run;
260
358
  }
261
359
  catch (e) {
262
- throw new Error(failureMessage(e));
360
+ if (resolvePromise === run)
361
+ resolvePromise = null; // провал не кешируем — даём повторить
362
+ throw e;
263
363
  }
264
364
  };
265
- export { resolveJava, detectSystemJava, cachedJava, jreCacheDir, adoptiumOs, adoptiumArch, parseMajor, MAJOR_MIN, TEMURIN_FEATURE };
365
+ // Публичный API модуля. jreCacheDir и JRE_CACHE_SCHEMA нужны подкоманде `jre info` —
366
+ // из неё CI берёт путь кеша и материал ключа (см. .github/workflows/ci.yml), не
367
+ // хардкодя путь и не grep'ая константы из исходников. adoptiumOs/adoptiumArch/
368
+ // parseMajor/MAJOR_MIN — module-private.
369
+ export { resolveJava, detectSystemJava, cachedJava, TEMURIN_FEATURE, JRE_CACHE_SCHEMA, jreCacheDir };
@@ -1,22 +1,16 @@
1
1
  import { createRequire } from 'node:module';
2
- import path from 'node:path';
3
- import { VENDOR_DIR } from "../../util/paths.js";
2
+ // Шрифт-пин общий с java-direct рендером (одна точка правды, см. fonts.ts).
3
+ import { FONTS_DIR, DEFAULT_FONT_NAME } from "./fonts.js";
4
4
  // @resvg/resvg-js — CJS-нативный аддон, грузим синхронно через createRequire
5
5
  // (ленивая загрузка ниже сохраняет прежнюю семантику: пакет тянется при первой
6
6
  // растеризации, не на импорте модуля).
7
7
  const require = createRequire(import.meta.url);
8
- // Растеризатор SVG→PNG (resvg, Rust/napi, prebuilt-бинарь, без браузера). Единая
9
- // детерминированная стадия PNG-выхода поверх SVG обоих движков (PlantUML и D2),
10
- // см. change resvg-png. Вендорный шрифт (тот же, что у java-direct) даёт кириллицу
11
- // и повторяемые метрики независимо от машины; системные шрифты отключены.
12
- const FONTS_DIR = path.join(VENDOR_DIR, 'fonts');
13
- const DEFAULT_FONT_NAME = 'Liberation Sans';
14
- let ResvgCtor = null;
8
+ let resvgMod = null;
15
9
  const getResvg = () => {
16
- if (ResvgCtor)
17
- return ResvgCtor;
10
+ if (resvgMod)
11
+ return resvgMod;
18
12
  try {
19
- ResvgCtor = require('@resvg/resvg-js').Resvg;
13
+ resvgMod = require('@resvg/resvg-js');
20
14
  }
21
15
  catch (err) {
22
16
  const e = err;
@@ -24,20 +18,20 @@ const getResvg = () => {
24
18
  'Установите его: npm install @resvg/resvg-js\n' +
25
19
  `Исходная ошибка: ${e.message || e}`);
26
20
  }
27
- return ResvgCtor;
21
+ return resvgMod;
28
22
  };
23
+ export const resvgFontOptions = (useSystemFonts) => useSystemFonts
24
+ ? { loadSystemFonts: true }
25
+ : { fontDirs: [FONTS_DIR], defaultFontFamily: DEFAULT_FONT_NAME, loadSystemFonts: false };
29
26
  // SVG (Buffer или строка) → PNG (Buffer), масштаб 1:1 к размеру SVG. Вход — валидный
30
27
  // SVG движка: PlantUML ссылается на шрифт по имени (грузим из vendor/fonts), D2 несёт
31
- // шрифт во вшитом @font-face, но loadSystemFonts:false фиксируем в обоих случаях.
32
- const rasterizeSvgToPng = (svg) => {
33
- const Resvg = getResvg();
34
- const resvg = new Resvg(svg, {
35
- font: {
36
- fontDirs: [FONTS_DIR],
37
- defaultFontFamily: DEFAULT_FONT_NAME,
38
- loadSystemFonts: false
39
- }
40
- });
41
- return resvg.render().asPng();
28
+ // шрифт во вшитом @font-face, но пин по умолчанию фиксируем в обоих случаях.
29
+ // renderAsync считает в napi-пуле тредов: JS-поток не блокируется, PlantUML-пул и
30
+ // D2-очередь продолжают крутиться параллельно растеризации (прежний sync render()
31
+ // останавливал весь event loop на каждую картинку).
32
+ const rasterizeSvgToPng = async (svg, useSystemFonts = false) => {
33
+ const { renderAsync } = getResvg();
34
+ const rendered = await renderAsync(svg, { font: resvgFontOptions(useSystemFonts) });
35
+ return rendered.asPng();
42
36
  };
43
37
  export { rasterizeSvgToPng };
@@ -2,6 +2,20 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import fsextra from 'fs-extra';
4
4
  import { makeDirectory, readFile } from "../../util/utils.js";
5
+ // Единый реестр движков диаграмм: расширение (с точкой) → рендерер + способности.
6
+ // ЕДИНСТВЕННЫЙ источник истины о том, какие файлы считаются диаграммами. Из него
7
+ // строятся фильтр scan (ниже), regex ссылок в compose/markdown.ts и ветвление
8
+ // «есть ли у движка онлайн-рендер» (build/compose). Добавление движка = одна строка
9
+ // здесь; фазы подхватят его без синхронной правки.
10
+ export const DIAGRAM_ENGINES = [
11
+ { ext: '.puml', engine: 'plantuml', remoteRender: true },
12
+ { ext: '.d2', engine: 'd2', remoteRender: false }
13
+ ];
14
+ // Расширение (lowercase, с точкой) → движок. undefined ⇒ файл не диаграмма.
15
+ export const engineForExt = (ext) => DIAGRAM_ENGINES.find((e) => e.ext === ext)?.engine;
16
+ // Есть ли у движка онлайн-рендер (сервер, как у PlantUML). Для движков без него (D2)
17
+ // сборка при выключенном generateLocalImages невозможна — вывод не из чего собрать.
18
+ export const engineSupportsRemote = (engine) => DIAGRAM_ENGINES.some((e) => e.engine === engine && e.remoteRender);
5
19
  export const getFolderName = (dir, root, homepage) => {
6
20
  return dir === root ? homepage : path.parse(dir).base;
7
21
  };
@@ -32,15 +46,31 @@ export const generateTree = async (dir, options) => {
32
46
  tree.push(item);
33
47
  }
34
48
  const IGNORED_FILES = ['CLAUDE.md'];
35
- const files = fs.readdirSync(dir).filter((x) => x.charAt(0) !== '_' && !IGNORED_FILES.includes(x));
49
+ // withFileTypes: один readdir вместо statSync на каждый файл (и второго — в
50
+ // otherFiles-цикле ниже). Симлинкам — честный statSync (Dirent по ссылке не идёт),
51
+ // чтобы симлинк-на-каталог обходился как раньше.
52
+ const entries = fs
53
+ .readdirSync(dir, { withFileTypes: true })
54
+ .filter((e) => e.name.charAt(0) !== '_' && !IGNORED_FILES.includes(e.name));
55
+ const isDir = new Map(entries.map((e) => [
56
+ e.name,
57
+ e.isSymbolicLink() ? fs.statSync(path.join(dir, e.name)).isDirectory() : e.isDirectory()
58
+ ]));
59
+ const files = entries.map((e) => e.name);
36
60
  for (const file of files) {
37
61
  //if folder
38
- if (fs.statSync(path.join(dir, file)).isDirectory()) {
62
+ const childDir = path.join(dir, file);
63
+ if (isDir.get(file)) {
64
+ // Выходной каталог не тащим в дерево: при rootFolder='.' distFolder был бы
65
+ // ребёнком корня → попадал в nav-меню (битая ссылка) и порождал docs/docs.
66
+ // Ранний return рекурсии срабатывал уже ПОСЛЕ push — отсекаем до него.
67
+ if (childDir === options.DIST_FOLDER)
68
+ continue;
39
69
  item.descendants.push(file);
40
70
  //create corresponding dist folder
41
71
  if (options.GENERATE_WEBSITE || options.GENERATE_MD || options.GENERATE_LOCAL_IMAGES)
42
72
  await makeDirectory(path.join(options.DIST_FOLDER, dir.replace(options.ROOT_FOLDER, ''), file));
43
- await build(path.join(dir, file), dir);
73
+ await build(childDir, dir);
44
74
  }
45
75
  }
46
76
  const mdFiles = files.filter((x) => path.extname(x).toLowerCase() === '.md');
@@ -48,12 +78,13 @@ export const generateTree = async (dir, options) => {
48
78
  const fileContents = await readFile(path.join(dir, mdFile));
49
79
  item.mdFiles.push(fileContents);
50
80
  }
51
- // Диаграммы обоих бэкендов: .puml → PlantUML, .d2 → D2. Поле dir — имя файла
81
+ // Диаграммы: расширение → движок берём из DIAGRAM_ENGINES. Поле dir — имя файла
52
82
  // (историческое), engine выбирает рендерер, isDitaa — только для PlantUML.
53
- const diagramFiles = files.filter((x) => ['.puml', '.d2'].includes(path.extname(x).toLowerCase()));
54
- for (const diagramFile of diagramFiles) {
83
+ for (const diagramFile of files) {
55
84
  const ext = path.extname(diagramFile).toLowerCase();
56
- const engine = ext === '.d2' ? 'd2' : 'plantuml';
85
+ const engine = engineForExt(ext);
86
+ if (!engine)
87
+ continue; // не диаграмма — пропускаем
57
88
  const fileContents = await readFile(path.join(dir, diagramFile));
58
89
  const isDitaa = engine === 'plantuml' &&
59
90
  !!(fileContents ? fileContents.toString() : '').match(/(@startditaa)/gi);
@@ -63,10 +94,14 @@ export const generateTree = async (dir, options) => {
63
94
  //copy all other files (.d2 исходники, как и .puml, не копируем — они рендерятся)
64
95
  const otherFiles = options.EXCLUDE_OTHER_FILES
65
96
  ? []
66
- : files.filter((x) => x.charAt(0) === '_' ||
67
- ['.md', '.puml', '.d2'].indexOf(path.extname(x).toLowerCase()) === -1);
97
+ : files.filter((x) => {
98
+ const ext = path.extname(x).toLowerCase();
99
+ // копируем всё, кроме .md и исходников диаграмм (их рендерим);
100
+ // _-файлы сюда не доходят — отфильтрованы на readdir выше.
101
+ return ext !== '.md' && engineForExt(ext) === undefined;
102
+ });
68
103
  for (const otherFile of otherFiles) {
69
- if (fs.statSync(path.join(dir, otherFile)).isDirectory())
104
+ if (isDir.get(otherFile))
70
105
  continue;
71
106
  if (options.GENERATE_MD || options.GENERATE_WEBSITE)
72
107
  await fsextra.copy(path.join(dir, otherFile), path.join(options.DIST_FOLDER, dir.replace(options.ROOT_FOLDER, ''), otherFile));
@@ -90,9 +125,17 @@ export const generateTree = async (dir, options) => {
90
125
  }
91
126
  return tree;
92
127
  };
128
+ // Кеш чтения include-файлов на одну сборку: N диаграмм × M включений иначе перечитывают
129
+ // одни и те же .iuml с диска (общие стили включаются каждой диаграммой). null = файл не
130
+ // прочитался (маркер, чтобы не ретраить). Сбрасывается clearIncludeCache() на границах
131
+ // build() — в watch-режиме иначе отдавались бы устаревшие данные.
132
+ const includeReadCache = new Map();
133
+ export const clearIncludeCache = () => includeReadCache.clear();
93
134
  // Свернуть локальные !include (.iuml и пр.) диаграммы в материал для чексуммы — рекурсивно.
94
135
  // Иначе правка включённого .iuml не инвалидирует кэш и на сайт уезжает устаревший рендер.
95
136
  // URL (!include https://…) и stdlib (!include <…>) пропускаем: локально не меняются.
137
+ // Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит от
138
+ // машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
96
139
  export const foldIncludes = (content, fileDir, searchDir, visited) => {
97
140
  const re = /^[ \t]*!include(?:_once|_many|sub|url)?[ \t]+(.+?)[ \t]*$/gim;
98
141
  let out = '';
@@ -111,14 +154,19 @@ export const foldIncludes = (content, fileDir, searchDir, visited) => {
111
154
  if (!resolved || visited.has(resolved))
112
155
  continue; // защита от циклов и повторов
113
156
  visited.add(resolved);
114
- let inc = '';
115
- try {
116
- inc = fs.readFileSync(resolved, 'utf-8');
157
+ let inc = includeReadCache.get(resolved);
158
+ if (inc === undefined) {
159
+ try {
160
+ inc = fs.readFileSync(resolved, 'utf-8');
161
+ }
162
+ catch {
163
+ inc = null;
164
+ }
165
+ includeReadCache.set(resolved, inc);
117
166
  }
118
- catch {
167
+ if (inc === null)
119
168
  continue;
120
- }
121
- out += ` ${resolved} ${inc}`;
169
+ out += ` ${path.relative(process.cwd(), resolved).split(path.sep).join('/')} ${inc}`;
122
170
  out += foldIncludes(inc, path.dirname(resolved), searchDir, visited); // вложенные include
123
171
  }
124
172
  return out;
@@ -0,0 +1,55 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ // Общий HTTP GET рендер-слоя (embed онлайн-рендера) и JRE-резолвера (скачивание с
4
+ // Adoptium). Следует редиректам и держит два таймаута — коннекта и простоя приёма:
5
+ // 3xx не роняет сборку, а молча повисший сокет не подвешивает её навсегда. Таймауты
6
+ // переопределяются env (в т.ч. для тестов); имена C4BUILDER_JRE_* поддержаны для
7
+ // обратной совместимости со старыми знобами JRE-резолвера.
8
+ const posInt = (v, dflt) => {
9
+ const n = Number(v);
10
+ return Number.isFinite(n) && n > 0 ? n : dflt;
11
+ };
12
+ const env = process.env;
13
+ const CONNECT_TIMEOUT_MS = posInt(env.C4BUILDER_HTTP_CONNECT_TIMEOUT_MS ?? env.C4BUILDER_JRE_CONNECT_TIMEOUT_MS, 30_000);
14
+ const IDLE_TIMEOUT_MS = posInt(env.C4BUILDER_HTTP_IDLE_TIMEOUT_MS ?? env.C4BUILDER_JRE_IDLE_TIMEOUT_MS, 60_000);
15
+ const MAX_REDIRECTS = 5;
16
+ // Возвращает поток успешного (2xx) ответа. Редиректы (assets-эндпоинт Adoptium
17
+ // 307-редиректит на github) следуются с лимитом против петель; не-2xx — reject.
18
+ export const httpGetStream = (url, opts = {}) => {
19
+ const { headers = {}, redirectsLeft = MAX_REDIRECTS } = opts;
20
+ return new Promise((resolve, reject) => {
21
+ const lib = url.startsWith('https') ? https : http;
22
+ const req = lib.get(url, { headers, timeout: CONNECT_TIMEOUT_MS }, (res) => {
23
+ const status = res.statusCode; // ответ всегда со статусом
24
+ const location = res.headers.location;
25
+ if (status >= 300 && status < 400 && location) {
26
+ res.resume();
27
+ if (redirectsLeft <= 0) {
28
+ return reject(new Error(`Слишком много редиректов (>${MAX_REDIRECTS}) для ${url}`));
29
+ }
30
+ const next = new URL(location, url).toString();
31
+ return resolve(httpGetStream(next, { headers, redirectsLeft: redirectsLeft - 1 }));
32
+ }
33
+ if (status < 200 || status > 299) {
34
+ res.resume();
35
+ return reject(new Error(`Не удалось загрузить ${url}, код ответа: ${status}`));
36
+ }
37
+ // Коннект установлен → с таймаута коннекта переключаемся на таймаут простоя
38
+ // приёма (крупный ответ тянется дольше): молчащий сокет уронит таймаут.
39
+ req.setTimeout(IDLE_TIMEOUT_MS);
40
+ resolve(res);
41
+ });
42
+ req.on('timeout', () => req.destroy(new Error(`Таймаут сети (${url})`)));
43
+ req.on('error', reject);
44
+ });
45
+ };
46
+ // Тело ответа целиком в Buffer.
47
+ export const httpGetBuffer = async (url, opts = {}) => {
48
+ const res = await httpGetStream(url, opts);
49
+ const chunks = [];
50
+ for await (const c of res)
51
+ chunks.push(c);
52
+ return Buffer.concat(chunks);
53
+ };
54
+ // Тело ответа как JSON.
55
+ export const httpGetJson = async (url, opts = {}) => JSON.parse((await httpGetBuffer(url, opts)).toString('utf8'));
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ // Межпроцессный advisory-лок сборки. Два одновременных запуска c4builder в одном
3
+ // каталоге (watch + ручная сборка) гоняются за dist/dist_bk и за RMW-записью
4
+ // .c4builder.cache (Configstore читает-модифицирует-пишет весь файл целиком) —
5
+ // теряются чексуммы, а переименования dist топчут друг друга. Лок-файл создаётся
6
+ // атомарно (flag 'wx'), владелец пишет в него свой pid и время захвата.
7
+ // Страховка от переиспользованного ОС-ю pid: лок старше часа считается протухшим,
8
+ // даже если pid формально жив (легитимная сборка длиннее часа — экзотика).
9
+ const STALE_MS = 60 * 60 * 1000;
10
+ // EPERM = процесс существует, но сигналить ему нельзя (чужой пользователь) — жив.
11
+ const ownerAlive = (pid) => {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch (e) {
17
+ return e.code === 'EPERM';
18
+ }
19
+ };
20
+ // Отдельный класс: watch-режим по нему отличает «занято соседней сборкой»
21
+ // (пропустить ребилд с коротким сообщением) от настоящего падения сборки.
22
+ export class BuildLockHeldError extends Error {
23
+ }
24
+ // Захват лока: возвращает release-колбэк. Занято живым процессом — BuildLockHeldError.
25
+ // Протухший лок (мёртвый владелец / нечитаемый файл / старше STALE_MS) снимается и
26
+ // захват повторяется один раз; в гонке двух «снявших» победит один, второй получит
27
+ // EEXIST со свежим живым владельцем и honest-ошибку.
28
+ export const acquireBuildLock = (lockPath) => {
29
+ for (let attempt = 0; attempt < 2; attempt++) {
30
+ try {
31
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), {
32
+ flag: 'wx'
33
+ });
34
+ return () => {
35
+ fs.rmSync(lockPath, { force: true });
36
+ };
37
+ }
38
+ catch (e) {
39
+ if (e.code !== 'EEXIST')
40
+ throw e;
41
+ let meta = {};
42
+ try {
43
+ meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
44
+ }
45
+ catch {
46
+ /* нечитаемый лок = протухший */
47
+ }
48
+ const alive = typeof meta.pid === 'number' && ownerAlive(meta.pid);
49
+ const fresh = typeof meta.at === 'number' && Date.now() - meta.at < STALE_MS;
50
+ if (alive && fresh) {
51
+ throw new BuildLockHeldError(`сборка уже идёт в этом каталоге (pid ${meta.pid}) — дождитесь её завершения ` +
52
+ `или удалите ${lockPath}, если это остаток упавшего процесса`);
53
+ }
54
+ fs.rmSync(lockPath, { force: true });
55
+ }
56
+ }
57
+ throw new BuildLockHeldError(`не удалось захватить лок сборки: ${lockPath}`);
58
+ };