@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.
- package/README.MD +11 -18
- package/dist/cli/commands/help.js +0 -2
- package/dist/cli/commands/jre.js +10 -3
- package/dist/cli/commands/list.js +9 -1
- package/dist/cli/commands/new.js +10 -29
- package/dist/cli/dispatch.js +135 -37
- package/dist/cli/wizard/collect.js +51 -91
- package/dist/config/defaults.js +4 -2
- package/dist/config/options.js +29 -1
- package/dist/config/schema.js +53 -32
- package/dist/core/build.js +74 -48
- package/dist/core/compose/docsify.template.js +2 -2
- package/dist/core/compose/markdown.js +146 -135
- package/dist/core/render/d2renderer.js +67 -11
- package/dist/core/render/diagrams.js +256 -100
- package/dist/core/render/fonts.js +8 -0
- package/dist/core/render/jre.js +155 -51
- package/dist/core/render/pngraster.js +18 -24
- package/dist/core/scan/tree.js +64 -16
- package/dist/util/http.js +55 -0
- package/dist/util/lock.js +58 -0
- package/dist/util/utils.js +10 -25
- package/package.json +5 -1
- package/vendor/fonts/NimbusSans-Bold.otf +0 -0
- package/vendor/fonts/NimbusSans-BoldItalic.otf +0 -0
- package/vendor/fonts/NimbusSans-Italic.otf +0 -0
- package/vendor/fonts/NimbusSans-Regular.otf +0 -0
- package/vendor/fonts/README.md +13 -10
- package/vendor/fonts/LiberationSans-Bold.ttf +0 -0
- package/vendor/fonts/LiberationSans-BoldItalic.ttf +0 -0
- package/vendor/fonts/LiberationSans-Italic.ttf +0 -0
- package/vendor/fonts/LiberationSans-Regular.ttf +0 -0
package/dist/core/render/jre.js
CHANGED
|
@@ -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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
return reject(new Error(`HTTP ${statusCode} для ${url}`));
|
|
162
|
+
catch {
|
|
163
|
+
/* не удалось снести — downloadJre всё равно перезапишет каталог */
|
|
136
164
|
}
|
|
137
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
226
|
-
fs.rmSync(
|
|
227
|
-
fs.mkdirSync(
|
|
228
|
-
await extractArchive(
|
|
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(
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
357
|
+
return await run;
|
|
260
358
|
}
|
|
261
359
|
catch (e) {
|
|
262
|
-
|
|
360
|
+
if (resolvePromise === run)
|
|
361
|
+
resolvePromise = null; // провал не кешируем — даём повторить
|
|
362
|
+
throw e;
|
|
263
363
|
}
|
|
264
364
|
};
|
|
265
|
-
|
|
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
|
-
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
17
|
-
return
|
|
10
|
+
if (resvgMod)
|
|
11
|
+
return resvgMod;
|
|
18
12
|
try {
|
|
19
|
-
|
|
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
|
|
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, но
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 };
|
package/dist/core/scan/tree.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
81
|
+
// Диаграммы: расширение → движок берём из DIAGRAM_ENGINES. Поле dir — имя файла
|
|
52
82
|
// (историческое), engine выбирает рендерер, isDitaa — только для PlantUML.
|
|
53
|
-
const
|
|
54
|
-
for (const diagramFile of diagramFiles) {
|
|
83
|
+
for (const diagramFile of files) {
|
|
55
84
|
const ext = path.extname(diagramFile).toLowerCase();
|
|
56
|
-
const engine = ext
|
|
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) =>
|
|
67
|
-
|
|
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 (
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
+
};
|