Compare commits

..

1 commit

Author SHA1 Message Date
renovate[bot]
5fa2a81f2f
Update dependency nodemailer to v9 [SECURITY] 2026-06-22 10:47:41 +00:00
30 changed files with 1338 additions and 3391 deletions

View file

@ -1,46 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { appendFileSync, statSync } from 'node:fs';
import { extname } from 'node:path';
import { fileURLToPath } from 'node:url';
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
function recordLoadedFile(kind, url, format) {
if (traceFile == null || !url.startsWith('file:')) return;
let filePath;
try {
filePath = fileURLToPath(url);
} catch {
return;
}
const extension = extname(filePath);
if (!jsExtensions.has(extension)) return;
let size = null;
try {
size = statSync(filePath).size;
} catch {
return;
}
appendFileSync(traceFile, `${JSON.stringify({
kind,
format,
path: filePath,
size,
timestamp: Date.now(),
})}\n`);
}
export async function load(url, context, nextLoad) {
const result = await nextLoad(url, context);
recordLoadedFile('esm', url, result.format ?? context.format ?? null);
return result;
}

View file

@ -1,46 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use strict';
const { appendFileSync, statSync } = require('node:fs');
const Module = require('node:module');
const { extname } = require('node:path');
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
function recordLoadedFile(kind, filePath, request) {
if (traceFile == null || typeof filePath !== 'string') return;
const extension = extname(filePath);
if (!jsExtensions.has(extension) && extension !== '.node') return;
let size = null;
try {
size = statSync(filePath).size;
} catch {
return;
}
appendFileSync(traceFile, `${JSON.stringify({
kind,
format: extension === '.node' ? 'native' : 'commonjs',
path: filePath,
request,
size,
timestamp: Date.now(),
})}\n`);
}
const originalLoad = Module._load;
const originalResolveFilename = Module._resolveFilename;
Module._load = function load(request, parent, isMain) {
const resolved = originalResolveFilename.call(this, request, parent, isMain);
const result = originalLoad.apply(this, arguments);
recordLoadedFile('cjs', resolved, request);
return result;
};

View file

@ -1,478 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { fork, spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { cpus, tmpdir } from 'node:os';
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { gzipSync } from 'node:zlib';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as http from 'node:http';
import * as util from './utility.mts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const [repoDirArg, outputFileArg] = process.argv.slice(2);
const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
const MAX_TABLE_ITEMS = util.readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
const repoDir = resolve(repoDirArg);
const outputFile = resolve(outputFileArg);
const backendDir = join(repoDir, 'packages/backend');
const backendBuiltDir = join(backendDir, 'built');
const traceFile = join(tmpdir(), `misskey-backend-js-footprint-${process.pid}-${Date.now()}.jsonl`);
const require = createRequire(join(repoDir, 'package.json'));
const ts = require('typescript');
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
const fileMetricCache = new Map();
const packageInfoCache = new Map();
const nativePackageNames = new Set();
function isInside(parent, child) {
const rel = relative(parent, child);
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
}
function normalizePath(filePath) {
return filePath.split(sep).join('/');
}
function bytesToKiB(value) {
return Math.round(value / 1024);
}
async function resetState() {
const backendRequire = createRequire(join(backendDir, 'package.json'));
const pg = backendRequire('pg');
const Redis = backendRequire('ioredis');
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
try {
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
} finally {
await postgres.end();
}
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
try {
await redis.flushall();
} finally {
redis.disconnect();
}
}
function createRequest() {
return new Promise((resolvePromise, reject) => {
const req = http.request({
host: 'localhost',
port: 61812,
path: '/api/meta',
method: 'POST',
}, res => {
res.on('data', () => { });
res.on('end', () => resolvePromise());
});
req.on('error', reject);
req.end();
});
}
async function waitForServerReady(serverProcess) {
let serverReady = false;
serverProcess.on('message', message => {
if (message === 'ok') serverReady = true;
});
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
}
async function stopServer(serverProcess) {
serverProcess.kill('SIGTERM');
let exited = false;
await new Promise(resolvePromise => {
serverProcess.on('exit', () => {
exited = true;
resolvePromise(undefined);
});
setTimeout(10000).then(() => {
if (!exited) serverProcess.kill('SIGKILL');
resolvePromise(undefined);
});
});
}
function getPackageNameFromPath(filePath) {
const normalized = normalizePath(filePath);
const marker = '/node_modules/';
const index = normalized.lastIndexOf(marker);
if (index === -1) return null;
const rest = normalized.slice(index + marker.length).split('/');
if (rest[0] === '.pnpm') {
const nestedNodeModulesIndex = rest.indexOf('node_modules');
if (nestedNodeModulesIndex === -1) return null;
const packageParts = rest.slice(nestedNodeModulesIndex + 1);
if (packageParts.length === 0) return null;
return packageParts[0].startsWith('@') ? packageParts.slice(0, 2).join('/') : packageParts[0];
}
return rest[0]?.startsWith('@') ? rest.slice(0, 2).join('/') : rest[0] ?? null;
}
function findPackageDir(filePath, packageName) {
const normalizedPackageName = packageName.split('/').join(sep);
let current = dirname(filePath);
while (current !== dirname(current)) {
if (current.endsWith(`${sep}${normalizedPackageName}`) && fsSync.existsSync(join(current, 'package.json'))) {
return current;
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}
function readPackageInfo(filePath) {
const externalPackageName = getPackageNameFromPath(filePath);
if (externalPackageName != null) {
const packageDir = findPackageDir(filePath, externalPackageName);
const cacheKey = packageDir ?? externalPackageName;
if (packageInfoCache.has(cacheKey)) return packageInfoCache.get(cacheKey);
let version = null;
if (packageDir != null) {
try {
const packageJson = JSON.parse(fsSync.readFileSync(join(packageDir, 'package.json'), 'utf8'));
version = typeof packageJson.version === 'string' ? packageJson.version : null;
} catch { }
}
const info = {
category: 'external',
name: externalPackageName,
version,
dir: packageDir,
};
packageInfoCache.set(cacheKey, info);
return info;
}
if (isInside(backendBuiltDir, filePath)) {
return {
category: 'internal',
name: 'backend',
version: null,
dir: backendDir,
};
}
return {
category: 'internal',
name: 'workspace',
version: null,
dir: repoDir,
};
}
function analyzeSource(filePath, source) {
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
const metrics = {
astNodeCount: 0,
functionCount: 0,
classCount: 0,
stringLiteralBytes: 0,
};
function visit(node) {
metrics.astNodeCount += 1;
if (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
ts.isGetAccessorDeclaration(node) ||
ts.isSetAccessorDeclaration(node)
) {
metrics.functionCount += 1;
} else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
metrics.classCount += 1;
} else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
metrics.stringLiteralBytes += Buffer.byteLength(node.text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return metrics;
}
function readFileMetrics(filePath) {
if (fileMetricCache.has(filePath)) return fileMetricCache.get(filePath);
const source = fsSync.readFileSync(filePath);
const sourceText = source.toString('utf8');
const astMetrics = analyzeSource(filePath, sourceText);
const packageInfo = readPackageInfo(filePath);
const metric = {
path: filePath,
displayPath: normalizePath(relative(repoDir, filePath)),
sourceBytes: source.byteLength,
gzipBytes: gzipSync(source).byteLength,
...astMetrics,
package: packageInfo,
};
fileMetricCache.set(filePath, metric);
return metric;
}
async function readTraceRecords() {
let content = '';
try {
content = await fs.readFile(traceFile, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const records = [];
for (const line of content.split('\n')) {
if (line.trim() === '') continue;
try {
records.push(JSON.parse(line));
} catch { }
}
return records;
}
function emptyTotals() {
return {
loadedJsModules: 0,
loadedJsSourceBytes: 0,
loadedJsGzipBytes: 0,
astNodeCount: 0,
functionCount: 0,
classCount: 0,
stringLiteralBytes: 0,
externalPackageCount: 0,
nativeAddonPackageCount: 0,
};
}
function addFileMetrics(target, metric) {
target.loadedJsModules += 1;
target.loadedJsSourceBytes += metric.sourceBytes;
target.loadedJsGzipBytes += metric.gzipBytes;
target.astNodeCount += metric.astNodeCount;
target.functionCount += metric.functionCount;
target.classCount += metric.classCount;
target.stringLiteralBytes += metric.stringLiteralBytes;
}
function summarizeRecords(records, phase) {
const jsPaths = new Set();
const nativePaths = new Set();
for (const record of records) {
if (typeof record.path !== 'string') continue;
const extension = extname(record.path);
if (jsExtensions.has(extension)) {
jsPaths.add(resolve(record.path));
} else if (extension === '.node') {
nativePaths.add(resolve(record.path));
}
}
for (const nativePath of nativePaths) {
const packageInfo = readPackageInfo(nativePath);
if (packageInfo.category === 'external') nativePackageNames.add(packageInfo.name);
}
const totals = emptyTotals();
const packages = new Map();
const modules = [];
for (const filePath of [...jsPaths].toSorted()) {
let metric;
try {
metric = readFileMetrics(filePath);
} catch (err) {
process.stderr.write(`Failed to analyze ${filePath}: ${err.message}\n`);
continue;
}
addFileMetrics(totals, metric);
const packageKey = metric.package.name;
if (!packages.has(packageKey)) {
packages.set(packageKey, {
name: metric.package.name,
version: metric.package.version,
category: metric.package.category,
sourceBytes: 0,
gzipBytes: 0,
modules: 0,
astNodeCount: 0,
functionCount: 0,
classCount: 0,
stringLiteralBytes: 0,
nativeAddon: false,
});
}
const packageSummary = packages.get(packageKey);
packageSummary.sourceBytes += metric.sourceBytes;
packageSummary.gzipBytes += metric.gzipBytes;
packageSummary.modules += 1;
packageSummary.astNodeCount += metric.astNodeCount;
packageSummary.functionCount += metric.functionCount;
packageSummary.classCount += metric.classCount;
packageSummary.stringLiteralBytes += metric.stringLiteralBytes;
modules.push({
path: metric.displayPath,
package: metric.package.name,
category: metric.package.category,
sourceBytes: metric.sourceBytes,
gzipBytes: metric.gzipBytes,
astNodeCount: metric.astNodeCount,
functionCount: metric.functionCount,
classCount: metric.classCount,
stringLiteralBytes: metric.stringLiteralBytes,
});
}
for (const packageName of nativePackageNames) {
const packageSummary = packages.get(packageName);
if (packageSummary != null) packageSummary.nativeAddon = true;
}
const externalPackages = [...packages.values()].filter(packageSummary => packageSummary.category === 'external');
totals.externalPackageCount = externalPackages.length;
totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length;
return {
totals: {
...totals,
loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes),
loadedJsGzipKiB: bytesToKiB(totals.loadedJsGzipBytes),
stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes),
},
packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes),
modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes).slice(0, MAX_TABLE_ITEMS),
};
}
async function measureFootprint() {
await fs.writeFile(traceFile, '');
process.stderr.write('Resetting database and Redis\n');
await resetState();
process.stderr.write('Running migrations\n');
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
env: process.env,
logStdout: true,
});
const serverProcess = fork(join(backendBuiltDir, 'entry.js'), [], {
cwd: backendDir,
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_ONLY_SERVER: '1',
MK_NO_DAEMONS: '1',
MK_BACKEND_JS_FOOTPRINT_TRACE: traceFile,
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: [
'--require',
join(__dirname, 'backend-js-footprint-require.cjs'),
'--experimental-loader',
pathToFileURL(join(__dirname, 'backend-js-footprint-loader.mjs')).href,
],
});
serverProcess.stdout?.on('data', data => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', data => {
process.stderr.write(`[server stderr] ${data}`);
});
serverProcess.on('error', err => {
process.stderr.write(`[server error] ${err}\n`);
});
try {
await waitForServerReady(serverProcess);
await setTimeout(SETTLE_TIME);
//const startup = summarizeRecords(await readTraceRecords(), 'startup');
await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
);
await setTimeout(1000);
const afterRequest = summarizeRecords(await readTraceRecords(), 'afterRequest');
return {
timestamp: new Date().toISOString(),
measurement: {
strategy: 'runtime-loader-trace',
startupTimeoutMs: STARTUP_TIMEOUT,
settleTimeMs: SETTLE_TIME,
requestCount: REQUEST_COUNT,
cpus: cpus().length,
},
phases: {
//startup,
afterRequest,
},
};
} finally {
await stopServer(serverProcess);
await fs.rm(traceFile, { force: true });
}
}
const result = await measureFootprint();
await fs.writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`);

View file

@ -1,673 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import * as util from './utility.mts';
import type { MemoryReport } from './measure-backend-memory-comparison.mts';
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
type RuntimeLoadedJsFootprintReport = {
phases: Record<'afterRequest', {
totals: {
loadedJsModules: number;
loadedJsSourceBytes: number;
loadedJsGzipBytes: number;
astNodeCount: number;
functionCount: number;
classCount: number;
stringLiteralBytes: number;
externalPackageCount: number;
nativeAddonPackageCount: number;
};
modules: {
path: string;
package: string;
category: string;
sourceBytes: number;
gzipBytes: number;
astNodeCount: number;
functionCount: number;
classCount: number;
stringLiteralBytes: number;
}[];
}>;
};
const memoryReportPhases = [
{
key: 'afterGc',
title: 'After GC',
},
] as const;
const metrics = [
'HeapUsed',
'Pss',
'Private_Dirty',
'VmRSS',
'External',
] as const;
const heapSnapshotCategoriesColors = {
'Total': 'gray',
'Code': 'orange',
'Strings': 'red',
'JS arrays': 'cyan',
'Typed arrays': 'green',
'System objects': 'yellow',
'Other JS objects': 'violet',
'Other non-JS objects': 'pink',
} as const;
const heapSnapshotCategoriesColorsHex = {
'Total': '#888888',
'Code': '#f28e2c',
'Strings': '#e15759',
'JS arrays': '#76b7b2',
'Typed arrays': '#59a14f',
'System objects': '#edc949',
'Other JS objects': '#af7aa1',
'Other non-JS objects': '#ff9da7',
} as const;
function formatMemoryMb(valueKiB: number | null | undefined) {
if (valueKiB == null) return '-';
return `${util.formatNumber(valueKiB / 1024)} MB`;
}
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
return report.summary[phase].memoryUsage[metric];
}
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
return sample.phases[phase].memoryUsage[metric];
}
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
if (values.length < 2) return null;
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
function getSamplesByRound(report: MemoryReport) {
const samplesByRound = new Map<number, MemoryReport['samples'][number]>();
if (!Array.isArray(report.samples)) return samplesByRound;
for (const sample of report.samples) {
if (sample.round <= 0) continue;
samplesByRound.set(sample.round, sample);
}
return samplesByRound;
}
function formatDeltaMemory(diffKiB: number) {
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
}
function pairedDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const baseSamplesByRound = getSamplesByRound(base);
const headSamplesByRound = getSamplesByRound(head);
const values = [];
for (const [round, baseSample] of baseSamplesByRound) {
const headSample = headSamplesByRound.get(round);
if (headSample == null) continue;
const baseValue = getMemoryValueFromSample(baseSample, phase, metric);
const headValue = getMemoryValueFromSample(headSample, phase, metric);
if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue);
}
return {
median: util.median(values),
mad: util.mad(values),
min: Math.min(...values),
max: Math.max(...values),
samples: values.length,
};
}
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
for (const metric of metrics) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
const summary = pairedDeltaSummary(base, head, phase, metric);
const percent = summary.median * 100 / baseValue;
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
}
return lines.join('\n');
}
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null || baseValue <= 0) return null;
return ((headValue - baseValue) * 100) / baseValue;
}
/*
function measurementSummary(base, head) {
const baseCount = base?.sampleCount;
const headCount = head?.sampleCount;
const strategy = base?.comparison?.strategy;
if (baseCount == null || headCount == null) return null;
if (strategy === 'interleaved-pairs') {
const rounds = base?.comparison?.rounds ?? baseCount;
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
}
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
}
*/
function formatPlainDelta(baseValue: number, headValue: number, formatter = util.formatNumber) {
const delta = headValue - baseValue;
if (delta === 0) return formatter(0);
const sign = delta > 0 ? '+' : '-';
return `${sign}${formatter(Math.abs(delta))}`;
}
function getHeapSnapshotCategoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const value = report.summary[phase]?.heapSnapshot?.categories?.[category];
return Number.isFinite(value) ? value : null;
}
function getHeapSnapshotCategoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const value = sample.phases[phase]?.heapSnapshot?.categories?.[category];
return Number.isFinite(value) ? value : null;
}
const heapSnapshotSankeyChildMinRatio = 0.3;
const heapSnapshotSankeyParentMinPercent = 10;
function escapeCsvValue(value: string) {
return `"${String(value).replaceAll('"', '""')}"`;
}
function formatSankeyPercentValue(value: number) {
const rounded = Math.round(value * 100) / 100;
if (rounded === 0 && value > 0) return '0.01';
if (Number.isInteger(rounded)) return String(rounded);
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function formatHeapSnapshotSankeyChildLabel(label: string) {
return String(label).replace(/^[^:]+:\s*/, '');
}
function renderHeapSnapshotSankey(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], title: string) {
const total = getHeapSnapshotCategoryValue(report, phase, 'Total');
if (total == null || total <= 0) return null;
function getHeapSnapshotBreakdownEntries(category: typeof util.heapSnapshotCategories[number]) {
const breakdown = report.summary[phase].heapSnapshot?.breakdowns?.[category];
if (breakdown == null || typeof breakdown !== 'object') return [];
return Object.entries(breakdown)
.filter(([, value]) => Number.isFinite(value) && value > 0)
.toSorted((a, b) => b[1] - a[1]);
}
const categories = util.heapSnapshotCategories
.filter(category => category !== 'Total')
.map(category => {
const value = getHeapSnapshotCategoryValue(report, phase, category);
if (value == null || value <= 0) return null;
const breakdownEntries = getHeapSnapshotBreakdownEntries(category);
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
const percent = (value * 100) / total;
const childEntries = [];
let otherPercent = 0;
if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) {
for (const [childName, childValue] of breakdownEntries) {
const childRatio = childValue / breakdownTotal;
const childPercent = percent * childRatio;
if (childRatio >= heapSnapshotSankeyChildMinRatio) {
childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]);
} else {
otherPercent += childPercent;
}
}
if (childEntries.length > 0 && otherPercent > 0) {
childEntries.push(['Other', otherPercent]);
}
}
return {
category,
percent,
childEntries,
};
})
.filter(value => value != null);
if (categories.length === 0) return null;
const nodeColors = {
[title]: heapSnapshotCategoriesColorsHex.Total,
} as Record<string, string>;
for (const { category, childEntries } of categories) {
const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total;
nodeColors[category] = categoryColor;
for (const [childName] of childEntries) {
nodeColors[childName] = categoryColor;
}
}
const lines = [
`<details><summary>${title} heap snapshot composition</summary>`,
'',
'```mermaid',
`%%{init: ${JSON.stringify({
sankey: {
showValues: false,
linkColor: 'target',
labelStyle: 'outlined',
nodeAlignment: 'center',
nodePadding: 10,
nodeColors: {
...nodeColors,
'Other': '#888888',
},
},
})}}%%`,
'sankey-beta',
];
for (const { category, percent, childEntries } of categories) {
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(category)},${formatSankeyPercentValue(percent)}`);
for (const [childName, childPercent] of childEntries) {
lines.push(`${escapeCsvValue(category)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
}
}
lines.push('```');
lines.push('');
lines.push('</details>');
return lines.join('\n');
}
function pairedHeapSnapshotDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const baseSamplesByRound = getSamplesByRound(base);
const headSamplesByRound = getSamplesByRound(head);
const values = [] as number[];
for (const [round, baseSample] of baseSamplesByRound) {
const headSample = headSamplesByRound.get(round);
if (headSample == null) continue;
const baseValue = getHeapSnapshotCategoryValueFromSample(baseSample, phase, category);
const headValue = getHeapSnapshotCategoryValueFromSample(headSample, phase, category);
if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue);
}
return {
median: util.median(values),
mad: util.mad(values),
min: Math.min(...values),
max: Math.max(...values),
samples: values.length,
};
}
function renderHeapSnapshotTable(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
const baseTotal = getHeapSnapshotCategoryValue(base, phase, 'Total');
const headTotal = getHeapSnapshotCategoryValue(head, phase, 'Total');
function formatHeapSnapshotCategoryLabel(category: typeof heapSnapshotCategories[number], baseValue: number, headValue: number, baseTotal: number, headTotal: number) {
if (category === 'Total' || baseTotal == null || headTotal == null || baseTotal <= 0 || headTotal <= 0) return `**${category}**`;
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
const headPercent = util.formatPercent((headValue * 100) / headTotal);
return `**${category}**<br>${basePercent}${headPercent}`;
}
function getHeapSnapshotSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
const values = report.samples
.map(sample => getHeapSnapshotCategoryValueFromSample(sample, phase, category))
.filter(value => Number.isFinite(value)) as number[];
if (values.length < 2) return null;
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
for (const category of util.heapSnapshotCategories) {
const baseValue = getHeapSnapshotCategoryValue(base, phase, category);
const headValue = getHeapSnapshotCategoryValue(head, phase, category);
if (baseValue == null || headValue == null) continue;
const baseSpread = getHeapSnapshotSampleSpread(base, phase, category);
const headSpread = getHeapSnapshotSampleSpread(head, phase, category);
const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category);
const percent = summary.median * 100 / baseValue;
const deltaMedian = summary == null ? '-' : `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal);
lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${util.formatBytes(baseValue)} <br> ± ${baseSpread == null ? '-' : util.formatBytes(baseSpread)} | ${util.formatBytes(headValue)} <br> ± ${headSpread == null ? '-' : util.formatBytes(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : util.formatBytes(summary.mad)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.min)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.max)} |`);
if (category === 'Total') {
lines.push('| | | | | | | |');
}
}
if (lines.length === 2) return null;
return lines.join('\n');
}
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const table = renderHeapSnapshotTable(base, head, 'afterGc');
if (table == null) return null;
const lines = [
'### V8 Heap Snapshot Statistics',
'',
table,
'',
];
for (const graph of [
renderHeapSnapshotSankey(base, 'afterGc', 'Base'),
renderHeapSnapshotSankey(head, 'afterGc', 'Head'),
]) {
if (graph == null) continue;
lines.push(graph);
lines.push('');
}
return lines.join('\n');
}
function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
const value = report.phases[phase].totals[key];
return Number.isFinite(value) ? value : null;
}
function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const metricRows = [
['Loaded JS modules', 'loadedJsModules', util.formatNumber],
['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
//['AST nodes', 'astNodeCount', util.formatNumber],
//['Functions', 'functionCount', util.formatNumber],
//['Classes', 'classCount', util.formatNumber],
//['String literals', 'stringLiteralBytes', util.formatBytes],
['External packages loaded', 'externalPackageCount', util.formatNumber],
['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
] as const;
const lines = [
'| Metric | Base | Head | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
for (const [title, key, formatter] of metricRows) {
const baseValue = getJsFootprintValue(base, 'afterRequest', key);
const headValue = getJsFootprintValue(head, 'afterRequest', key);
if (baseValue == null || headValue == null) continue;
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatPlainDelta(baseValue, headValue, formatter)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue)} |`);
}
return lines.join('\n');
}
/*
function renderJsFootprintPhaseTable(base, head) {
const lines = [
'| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) {
const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules');
const headModules = getJsFootprintValue(head, phase, 'loadedJsModules');
const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes');
const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes');
if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue;
lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`);
}
return lines.join('\n');
}
*/
function packageMap(report: RuntimeLoadedJsFootprintReport) {
const map = new Map();
for (const packageSummary of report.phases.afterRequest.packages) {
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
map.set(packageSummary.name, packageSummary);
}
return map;
}
function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
if (packageSummary.version == null) return packageSummary.name;
return `${packageSummary.name} ${packageSummary.version}`;
}
function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const basePackages = packageMap(base);
const headPackages = packageMap(head);
const newPackages = [...headPackages.values()]
.filter(packageSummary => !basePackages.has(packageSummary.name))
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
.slice(0, 10);
if (newPackages.length === 0) return null;
const lines = [
'#### Newly Loaded External Packages',
'',
'| Package | Loaded JS | Modules | Notes |',
'| --- | ---: | ---: | --- |',
];
for (const packageSummary of newPackages) {
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
}
return lines.join('\n');
}
function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const basePackages = packageMap(base);
const headPackages = packageMap(head);
const increases = [...headPackages.values()]
.map(headPackage => {
const basePackage = basePackages.get(headPackage.name);
const baseSourceBytes = basePackage?.sourceBytes ?? 0;
const baseModules = basePackage?.modules ?? 0;
return {
...headPackage,
baseSourceBytes,
baseModules,
sourceDiff: headPackage.sourceBytes - baseSourceBytes,
moduleDiff: headPackage.modules - baseModules,
};
})
.filter(packageSummary => packageSummary.sourceDiff > 0)
.toSorted((a, b) => b.sourceDiff - a.sourceDiff)
.slice(0, 10);
if (increases.length === 0) return null;
const lines = [
'#### Largest Package Increases',
'',
'| Package | Base | Head | Δ | Modules Δ |',
'| --- | ---: | ---: | ---: | ---: |',
];
for (const packageSummary of increases) {
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${formatPlainDelta(packageSummary.baseSourceBytes, packageSummary.sourceBytes, util.formatBytes)} | ${formatPlainDelta(packageSummary.baseModules, packageSummary.modules)} |`);
}
return lines.join('\n');
}
function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
function moduleMap(report: RuntimeLoadedJsFootprintReport) {
const map = new Map();
for (const moduleSummary of report.phases.afterRequest.modules) {
if (typeof moduleSummary.path !== 'string') continue;
map.set(moduleSummary.path, moduleSummary);
}
return map;
}
const baseModules = moduleMap(base);
const headModules = moduleMap(head);
const newModules = [...headModules.values()]
.filter(moduleSummary => !baseModules.has(moduleSummary.path))
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
.slice(0, 10);
if (newModules.length === 0) return null;
const lines = [
'#### Largest Newly Loaded Modules',
'',
'| Module | Package | Loaded JS |',
'| --- | --- | ---: |',
];
for (const moduleSummary of newModules) {
lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`);
}
return lines.join('\n');
}
function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
const lines = [
'### Runtime Loaded JS Footprint',
'',
'<details><summary>Click to show</summary>',
'',
renderJsFootprintMetricTable(base, head),
'',
//'#### Load Phase Breakdown',
//'',
//renderJsFootprintPhaseTable(base, head),
//'',
];
for (const block of [
renderNewExternalPackages(base, head),
renderLargestPackageIncreases(base, head),
renderNewLoadedModules(base, head),
]) {
if (block == null) continue;
lines.push(block);
lines.push('');
}
lines.push('</details>');
lines.push('');
return lines.join('\n');
}
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
const lines = [
'## ⚙️ Backend Memory Usage Report',
'',
];
//const summary = measurementSummary(base, head);
//if (summary != null) {
// lines.push(summary);
// lines.push('');
//}
for (const phase of memoryReportPhases) {
lines.push(`### ${phase.title}`);
lines.push(renderMainTableForPhase(base, head, phase.key));
lines.push('');
}
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
if (heapSnapshotSection != null) {
lines.push(heapSnapshotSection);
lines.push('');
}
const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
if (jsFootprintSection != null) {
lines.push(jsFootprintSection);
lines.push('');
}
function getWarningMetric(base: MemoryReport, head: MemoryReport) {
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS'] as const) {
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
return metric;
}
}
return null;
}
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
if (baseValue == null || headValue == null) return false;
const diff = headValue - baseValue;
if (diff <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return diff > combinedSpread * 3;
}
const warningMetric = getWarningMetric(base, head);
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
lines.push('');
}
lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
await writeFile(outputFile, `${lines.join('\n')}\n`);

View file

@ -0,0 +1,276 @@
import { readFile, writeFile } from 'node:fs/promises';
const [beforeFile, afterFile, outputFile] = process.argv.slice(2);
if (beforeFile == null || afterFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <before-stats.json> <after-stats.json> <report.md>');
process.exit(1);
}
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatPercent(value) {
return `${Math.round(value)}%`;
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return formatPercent((value / total) * 100);
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiff(before, after, formatter) {
const diff = after - before;
if (diff === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff);
}
function formatDiffPercent(before, after) {
if (before === 0 && after === 0) return '0%';
if (before === 0) return '-';
const diff = after - before;
if (diff === 0) return '0%';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff);
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function collectReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${formatDiff(before.summary[key], after.summary[key], formatNumber)}</td>`),
...metrics.map((key) => `<td>${formatDiff(before.metrics[key], after.metrics[key], formatBytes)}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
const beforeData = JSON.parse(await readFile(beforeFile, 'utf8'));
const afterData = JSON.parse(await readFile(afterFile, 'utf8'));
const before = collectReport(beforeData);
const after = collectReport(afterData);
const lines = [
'## Frontend Bundle Report',
'',
...renderSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
await writeFile(outputFile, `${lines.join('\n')}\n`);

344
.github/scripts/frontend-js-size.mjs vendored Normal file
View file

@ -0,0 +1,344 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function fileSize(filePath) {
const stat = await fs.stat(filePath);
return stat.size;
}
async function* walk(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
function formatBytes(size) {
if (size == null) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${stripTrailingZeros((size / 1024).toFixed(1))} KiB`;
return `${stripTrailingZeros((size / 1024 / 1024).toFixed(2))} MiB`;
}
function stripTrailingZeros(value) {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\\\%');
}
function formatDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
const sign = diff > 0 ? '+' : '-';
const text = `${sign}${formatBytes(Math.abs(diff))}`;
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiffPercent(beforeSize, afterSize) {
if (beforeSize == null || beforeSize === 0 || afterSize == null || afterSize === 0) return '-';
const diff = afterSize - beforeSize;
if (diff === 0) return `0%`;
const percent = Math.round(diff / beforeSize * 100);
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(percent.toString() + '%')}}}$`;
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName || entry.file;
}
function findEntryKey(manifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
?? null;
}
function stableChunkKey(manifestKey, chunk) {
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
}
function collectStartupKeys(manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set();
if (entryKey == null) return keys;
function visit(key) {
if (keys.has(key)) return;
const chunk = manifest[key];
if (!chunk || !chunk.file?.endsWith('.js')) return;
keys.add(stableChunkKey(key, chunk));
for (const importKey of chunk.imports ?? []) {
visit(importKey);
}
}
visit(entryKey);
return keys;
}
async function resolveBuiltFile(outDir, file) {
if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile);
if (await exists(localizedPath)) {
return {
absolutePath: localizedPath,
relativePath: `${locale}/${localizedFile}`,
};
}
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
}
return {
absolutePath: path.join(outDir, file),
relativePath: file,
};
}
async function collectReport(repoDir) {
const outDir = path.join(repoDir, 'built/_frontend_vite_');
const manifestPath = path.join(outDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
const byKey = new Map();
const byFile = new Set();
for (const [key, chunk] of Object.entries(manifest)) {
if (!chunk.file?.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file);
const size = await fileSize(builtFile.absolutePath);
const stableKey = stableChunkKey(key, chunk);
const displayName = chunk.src ?? chunk.name ?? key;
byKey.set(stableKey, {
key: stableKey,
displayName,
file: builtFile.relativePath,
size,
});
byFile.add(builtFile.relativePath);
}
const localeDir = path.join(outDir, locale);
if (await exists(localeDir)) {
for await (const fullPath of walk(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
const size = await fileSize(fullPath);
byKey.set(relativePath, {
key: relativePath,
displayName: relativePath,
file: relativePath,
size,
});
}
}
return {
manifest,
chunks: Object.fromEntries(byKey),
startupKeys: [...collectStartupKeys(manifest)],
};
}
function commonKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] != null);
}
function addedKeys(before, after) {
return Object.keys(after.chunks)
.filter((key) => before.chunks[key] == null);
}
function removedKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] == null);
}
function getChunkComparisonRows(keys, before, after) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
const afterEntry = after.chunks[key];
const beforeSize = beforeEntry?.size ?? 0;
const afterSize = afterEntry?.size ?? 0;
return {
key,
name: entryDisplayName(beforeEntry ?? afterEntry),
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function markdownTable(rows, total) {
if (rows.length === 0) return '_No data_';
const lines = [
'| Chunk | Before | After | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
}
return lines.join('\n');
}
function chunkRows(keys, report) {
return keys.map((key) => {
const entry = report.chunks[key];
return {
key,
name: entryDisplayName(entry),
chunkFile: entry.file,
size: entry.size,
};
});
}
function markdownChunkTable(rows) {
if (rows.length === 0) return '_No data_';
const lines = [
'| Chunk | Size |',
'| --- | ---: |',
];
for (const row of rows) {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.size)} |`);
}
return lines.join('\n');
}
const beforeDir = process.argv[2];
const afterDir = process.argv[3];
const outFile = process.argv[4];
const beforeSha = process.env.BASE_SHA;
const afterSha = process.env.HEAD_SHA;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const commonChunkKeys = commonKeys(before, after);
const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const diffRows = comparisonRows
.filter((row) => row.beforeSize !== row.afterSize)
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name))
.slice(0, 30);
const diffTotal = {
beforeSize: comparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: comparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const addedRows = chunkRows(addedKeys(before, after), after)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const removedRows = chunkRows(removedKeys(before, after), before)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name));
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const largeRows = comparisonRows
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
.slice(0, 30);
const body = [
marker,
`## Frontend chunk report (${locale})`,
'',
'<details open>',
`<summary>Diffs</summary>`,
'',
markdownTable(diffRows, diffTotal),
'',
'</details>',
'',
'<details>',
`<summary>Added (${addedRows.length})</summary>`,
'',
markdownChunkTable(addedRows),
'',
'</details>',
'',
'<details>',
`<summary>Removed (${removedRows.length})</summary>`,
'',
markdownChunkTable(removedRows),
'',
'</details>',
'',
'<details>',
`<summary>Startup</summary>`,
'',
markdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
'<details>',
`<summary>Largest</summary>`,
'',
markdownTable(largeRows),
'',
'</details>',
'',
].join('\n');
await fs.writeFile(outFile, body);

View file

@ -1,568 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import * as util from './utility.mts';
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
//function sharePercent(value, total) {
// if (total === 0) return '0%';
// return Math.round((value / total) * 100) + '%';
//}
function escapeCell(value: string) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
//function tableCell(value) {
// return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
//}
//function code(value) {
// const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
// const backtickRuns = sanitized.match(/`+/g) ?? [];
// const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
// const fence = '`'.repeat(fenceLength);
// const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
//
// return `${fence}${padding}${sanitized}${padding}${fence}`;
//}
//function tableCode(value) {
// return tableCell(code(value));
//}
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
type FileEntry = {
key: string;
displayName: string;
file: string;
size: number;
};
function entryDisplayName(entry: FileEntry) {
if (!entry) return '';
return entry.displayName || entry.file;
}
function findEntryKey(manifest: Manifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
?? null;
}
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
}
function collectStartupKeys(manifest: Manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<string>();
if (entryKey == null) return keys;
function visit(key: string) {
if (keys.has(key)) return;
const chunk = manifest[key];
if (!chunk || !chunk.file?.endsWith('.js')) return;
keys.add(stableChunkKey(key, chunk));
for (const importKey of chunk.imports ?? []) {
visit(importKey);
}
}
visit(entryKey);
return keys;
}
async function resolveBuiltFile(outDir: string, file: string) {
if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile);
if (await util.fileExists(localizedPath)) {
return {
absolutePath: localizedPath,
relativePath: `${locale}/${localizedFile}`,
};
}
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
}
return {
absolutePath: path.join(outDir, file),
relativePath: file,
};
}
async function collectReport(repoDir: string) {
const outDir = path.join(repoDir, 'built/_frontend_vite_');
const manifestPath = path.join(outDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
const byKey = new Map<string, FileEntry>();
const byFile = new Set<string>();
for (const [key, chunk] of Object.entries(manifest)) {
if (!chunk.file?.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file);
const size = await util.fileSize(builtFile.absolutePath);
const stableKey = stableChunkKey(key, chunk);
const displayName = chunk.src ?? chunk.name ?? key;
byKey.set(stableKey, {
key: stableKey,
displayName,
file: builtFile.relativePath,
size,
});
byFile.add(builtFile.relativePath);
}
const localeDir = path.join(outDir, locale);
if (await util.fileExists(localeDir)) {
for await (const fullPath of util.traverseDirectory(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
const size = await util.fileSize(fullPath);
byKey.set(relativePath, {
key: relativePath,
displayName: relativePath,
file: relativePath,
size,
});
}
}
return {
manifest,
chunks: Object.fromEntries(byKey),
startupKeys: [...collectStartupKeys(manifest)],
};
}
type VisualizerReport = {
nodeParts?: Record<string, {
renderedLength: number;
gzipLength: number;
brotliLength: number;
}>;
nodeMetas?: Record<string, {
id: string;
isEntry?: boolean;
isExternal?: boolean;
importedBy?: string[];
imported?: { id: string; dynamic?: boolean }[];
moduleParts?: Record<string, string>;
renderedLength: number;
gzipLength: number;
brotliLength: number;
}>;
options?: Record<string, unknown>;
};
function collectVisualizerReport(data: VisualizerReport) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
] as const;
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
] as const;
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
const afterEntry = after.chunks[key];
const beforeSize = beforeEntry?.size ?? 0;
const afterSize = afterEntry?.size ?? 0;
return {
key,
name: entryDisplayName(beforeEntry ?? afterEntry),
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
removed: rows.filter((row) => row.changeType === 'removed').length,
};
}
function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[number]) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
if (rows.length === 0) return '_No data_';
const lines = [
'| Chunk | Before | After | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
}
}
return lines.join('\n');
}
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
const allChunkKeys = [
...commonChunkKeys,
...addedChunkKeys,
...removedChunkKeys,
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
return [
'<details open>',
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
].join('\n');
}
function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
const lines = [
...renderVisualizerSummaryTable(before, after),
'',
//'<details>',
//'<summary>Top 10</summary>',
//'',
];
/*
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
*/
return lines.join('\n');
}
const visualizerTreemapLimit = 50;
function mermaidTreemapLabel(value: string) {
const label = String(value)
.replaceAll('\\', '/')
.replaceAll('"', "'")
.replaceAll('`', "'")
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.trim();
return label === '' ? '(unknown)' : label;
}
function mermaidTreemapModuleLabel(id: string) {
const normalizedId = String(id).replaceAll('\\', '/');
const filePath = normalizedId.split(/[?#]/, 1)[0];
const fileName = path.posix.basename(filePath);
return mermaidTreemapLabel(fileName || normalizedId);
}
function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
const rows = report.hotModules
.filter((row) => row.renderedLength > 0)
.slice(0, visualizerTreemapLimit);
const topRendered = rows.reduce((sum, row) => sum + row.renderedLength, 0);
const otherRendered = Math.max(0, report.metrics.renderedLength - topRendered);
const lines = [
'```mermaid',
`%%{init: ${JSON.stringify({
treemap: {
diagramPadding: 0,
padding: 0,
nodeHeight: 70,
},
})}}%%`,
'treemap-beta',
`"${mermaidTreemapLabel(label)}"`,
];
for (const row of rows) {
lines.push(` "${mermaidTreemapModuleLabel(row.id)}": ${Math.round(row.renderedLength)}`);
}
if (otherRendered > 0) {
lines.push(` "Other": ${Math.round(otherRendered)}`);
}
lines.push('```');
return lines.join('\n');
}
function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
return [
`<details${open ? ' open' : ''}>`,
`<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`,
'',
renderVisualizerTreemap(label, report),
'',
'</details>',
].join('\n');
}
const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
const afterVisualizerReport = collectVisualizerReport(afterStats);
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
const body = [
marker,
'',
`## 📦 Frontend Bundle Report`,
'',
renderFrontendChunkReport(before, after),
'',
'## Bundle Stats',
'',
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
'',
renderVisualizerTreemapDetails('Before', beforeVisualizerReport),
'',
renderVisualizerTreemapDetails('After', afterVisualizerReport),
'',
visualizerArtifactLink,
].join('\n');
await fs.writeFile(outFile, body);

View file

@ -1,262 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import * as util from './utility.mts';
import type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts';
const phases = ['afterGc'] as const;
export type MemoryReport = {
timestamp: string;
sampleCount: any;
aggregation: string;
measurement: {
startupTimeoutMs: any;
memorySettleTimeMs: any;
ipcTimeoutMs: any;
requestCount: any;
heapSnapshot: {
enabled: any;
timeoutMs: any;
breakdownTopN: any;
};
};
summary: Record<typeof phases[number], {
memoryUsage: Record<string, number>;
heapSnapshot?: {
categories: Record<typeof util.heapSnapshotCategories[number], number>;
nodeCounts: Record<typeof util.heapSnapshotCategories[number], number>;
breakdowns?: Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
};
}>;
samples: (MemoryReportRaw['samples'][number] & {
round: number;
})[];
};
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
async function resetState(repoDir: string) {
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
const pg = require('pg');
const Redis = require('ioredis');
const postgres = new pg.Client({
host: '127.0.0.1',
port: 54312,
database: 'postgres',
user: 'postgres',
});
await postgres.connect();
try {
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
await postgres.query('CREATE DATABASE "test-misskey"');
} finally {
await postgres.end();
}
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
try {
await redis.flushall();
} finally {
redis.disconnect();
}
}
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
const breakdowns = {} as Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
for (const category of util.heapSnapshotCategories) {
if (category === 'Total') continue;
const childKeys = new Set<string>();
for (const sample of samples) {
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
.filter(value => Number.isFinite(value));
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
}
if (Object.keys(categoryBreakdown).length > 0) {
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
}
}
return breakdowns;
}
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
function summarizeSamples(samples: MemoryReport['samples']) {
const summary = {} as MemoryReport['summary'];
for (const phase of phases) {
summary[phase] = {
memoryUsage: {},
};
const metricKeys = new Set<string>();
for (const sample of samples) {
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
metricKeys.add(key);
}
}
for (const key of metricKeys) {
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
summary[phase].memoryUsage[key] = util.median(values);
}
const heapSnapshotCategoryValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
for (const category of util.heapSnapshotCategories) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
}
const heapSnapshotNodeCountValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
for (const category of util.heapSnapshotCategories) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
}
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
summary[phase].heapSnapshot = {
categories: heapSnapshotCategoryValues,
nodeCounts: heapSnapshotNodeCountValues,
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
};
}
}
return summary;
}
async function measureRepo(label: string, repoDir: string, round: number) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState(repoDir);
process.stderr.write(`[${label}] Running migrations\n`);
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
env: process.env,
logStdout: true,
});
process.stderr.write(`[${label}] Measuring memory\n`);
const measureEnv = {
...process.env,
MK_MEMORY_SAMPLE_COUNT: '1',
} as NodeJS.ProcessEnv;
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], {
cwd: repoDir,
env: measureEnv,
});
const report = JSON.parse(stdout) as MemoryReportRaw;
const sample = report.samples[0];
return sample;
}
async function main() {
const baseDir = resolve(baseDirArg);
const headDir = resolve(headDirArg);
const baseOutput = resolve(baseOutputArg);
const headOutput = resolve(headOutputArg);
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
const reports = {
base: {
dir: baseDir,
samples: [] as MemoryReport['samples'],
},
head: {
dir: headDir,
samples: [] as MemoryReport['samples'],
},
};
for (let round = 1; round <= warmupRounds; round++) {
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
for (const label of ['base', 'head'] as const) {
await measureRepo(label, reports[label].dir, -round);
}
}
for (let round = 1; round <= rounds; round++) {
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
for (const [orderIndex, label] of order.entries()) {
const sample = await measureRepo(label, reports[label].dir, round);
reports[label].samples.push({
...sample,
round,
});
}
}
for (const label of ['base', 'head'] as const) {
const report = {
timestamp: new Date().toISOString(),
sampleCount: reports[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
startedAt,
},
summary: summarizeSamples(reports[label].samples),
samples: reports[label].samples,
};
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View file

@ -1,179 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
export const heapSnapshotCategories = [
'Total',
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
] as const;
export function median(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
export function mad(values: number[]) {
if (values.length < 2) return null;
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
export function normalizePath(filePath: string) {
return filePath.split(path.sep).join('/');
}
export async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function fileSize(filePath: string) {
const stat = await fs.stat(filePath);
return stat.size;
}
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* traverseDirectory(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
export function escapeLatex(text: string) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
export function formatColoredDelta(text: string, delta: number) {
if (delta === 0) return text;
const color = delta > 0 ? 'orange' : 'green';
const sign = delta > 0 ? '+' : '-';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
}
const numberFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 1,
});
export function formatNumber(value: number) {
return numberFormatter.format(value);
}
export function formatBytes(value: number) {
if (value === 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
export function calcAndFormatDeltaNumber(before: number, after: number) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
}
export function formatDeltaBytes(deltaBytes: number) {
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
}
export function calcAndFormatDeltaBytes(before: number, after: number) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatDeltaBytes(delta);
}
export function formatPercent(value: number) {
return `${formatNumber(value)}%`;
}
export function formatDeltaPercent(deltaPercent: number) {
if (deltaPercent === 0) return '0%';
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
}
export function calcAndFormatDeltaPercent(before: number, after: number) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const delta = after - before;
return formatDeltaPercent(delta / before * 100);
}
export function commandName(command: string) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
return new Promise<string>((resolvePromise, reject) => {
const child = spawn(commandName(command), args, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
if (options.logStdout) process.stderr.write(data);
});
child.stderr.on('data', data => {
stderr += data;
process.stderr.write(data);
});
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolvePromise(stdout);
} else {
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
}
});
});
}

View file

@ -25,8 +25,8 @@ on:
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/scripts/utility.mts
- .github/scripts/frontend-js-size.mts
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@ -104,9 +104,6 @@ jobs:
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) return { done: true, run };
core.info(`Frontend bundle report run ${run.id} did not produce ${artifactName}.`);
return { done: true, run: null };
}
return { done: false, run: null };
@ -129,30 +126,8 @@ jobs:
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
- name: Find bundle report artifact
if: github.event_name == 'workflow_run'
id: find-report-artifact
uses: actions/github-script@v9
with:
script: |
const artifactName = 'frontend-bundle-report';
const { owner, repo } = context.repo;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: context.payload.workflow_run.id,
per_page: 100,
});
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
if (report) {
core.setOutput('exists', 'true');
} else {
core.info(`Workflow run ${context.payload.workflow_run.id} did not produce ${artifactName}.`);
core.setOutput('exists', 'false');
}
- name: Download bundle report from workflow_run
if: github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true'
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v8
with:
name: frontend-bundle-report
@ -172,7 +147,7 @@ jobs:
run-id: ${{ steps.find-report-run.outputs.run-id }}
- name: Comment on pull request
if: (github.event_name == 'workflow_run' && steps.find-report-artifact.outputs.exists == 'true') || steps.find-report-run.outputs.run-id != ''
if: github.event_name == 'workflow_run' || steps.find-report-run.outputs.run-id != ''
uses: actions/github-script@v9
with:
github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }}
@ -185,25 +160,30 @@ jobs:
const reportMarkers = [jsSizeMarker, visualizerMarker];
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
const visualizerReportPath = path.join(reportDir, 'frontend-bundle-visualizer-report.md');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
const headShaPath = path.join(reportDir, 'head-sha.txt');
const workflowRun = context.payload.workflow_run;
const pullRequest = context.payload.pull_request;
const eventHeadSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
const headSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
const { owner, repo } = context.repo;
if (!fs.existsSync(jsSizeReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
return;
}
if (!fs.existsSync(visualizerReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-bundle-visualizer-report.md.');
return;
}
const artifactHeadSha = fs.existsSync(headShaPath)
? fs.readFileSync(headShaPath, 'utf8').trim()
: null;
if (eventHeadSha != null && artifactHeadSha != null && artifactHeadSha !== eventHeadSha) {
core.info(`The artifact head SHA (${artifactHeadSha}) differs from the event head SHA (${eventHeadSha}). Using artifact metadata for PR validation.`);
if (headSha != null && artifactHeadSha != null && artifactHeadSha !== headSha) {
core.setFailed(`The artifact head SHA (${artifactHeadSha}) does not match the workflow head SHA (${headSha}).`);
return;
}
const reportHeadSha = artifactHeadSha ?? eventHeadSha;
const artifactPrNumber = fs.existsSync(prNumberPath)
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
@ -223,29 +203,25 @@ jobs:
}
}
if (reportHeadSha != null) {
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner,
repo,
commit_sha: reportHeadSha,
per_page: 100,
});
for (const pullRequest of pullRequestsForCommit) {
associatedPullRequests.set(pullRequest.number, pullRequest);
}
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner,
repo,
commit_sha: headSha,
per_page: 100,
});
for (const pullRequest of pullRequestsForCommit) {
associatedPullRequests.set(pullRequest.number, pullRequest);
}
if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) {
issue_number = artifactPrNumber;
} else if (Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 0) {
issue_number = artifactPrNumber;
} else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) {
issue_number = [...associatedPullRequests.keys()][0];
} else if (Number.isInteger(artifactPrNumber)) {
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${reportHeadSha}.`);
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${headSha}.`);
return;
} else {
core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`);
core.setFailed(`Could not determine the pull request associated with ${headSha}.`);
return;
}
} else {
@ -253,23 +229,16 @@ jobs:
return;
}
const currentPullRequest = await github.rest.pulls.get({
owner,
repo,
pull_number: issue_number,
});
const currentHeadSha = currentPullRequest.data.head?.sha;
if (reportHeadSha != null && currentHeadSha != null && reportHeadSha !== currentHeadSha) {
core.info(`The report head SHA (${reportHeadSha}) is not the current pull request head SHA (${currentHeadSha}). Skipping stale frontend bundle report.`);
return;
}
const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim();
if (!jsSizeReport.includes(jsSizeMarker)) {
core.setFailed('The frontend JS size report is missing the expected marker.');
return;
}
let body = `${jsSizeReport}\n`;
const visualizerReport = fs.readFileSync(visualizerReportPath, 'utf8').trim();
let body = [
jsSizeReport,
visualizerReport,
].join('\n\n') + '\n';
const maxCommentLength = 65_000;
if (body.length > maxCommentLength) {

View file

@ -20,8 +20,8 @@ on:
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/scripts/utility.mts
- .github/scripts/frontend-js-size.mts
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@ -56,25 +56,21 @@ jobs:
path: after
submodules: true
- name: Check base visualizer support
id: check-base-visualizer
- name: Backport visualizer tooling to base if needed
shell: bash
run: |
if grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
echo 'supported=true' >> "$GITHUB_OUTPUT"
else
echo 'supported=false' >> "$GITHUB_OUTPUT"
echo 'Base commit does not support frontend bundle visualizer. Skipping frontend bundle report.' >> "$GITHUB_STEP_SUMMARY"
if ! grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
cp after/packages/frontend/package.json before/packages/frontend/package.json
cp after/packages/frontend/vite.config.ts before/packages/frontend/vite.config.ts
cp after/pnpm-lock.yaml before/pnpm-lock.yaml
fi
- name: Setup pnpm
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
@ -84,84 +80,67 @@ jobs:
after/pnpm-lock.yaml
- name: Install dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare report output
if: steps.check-base-visualizer.outputs.supported == 'true'
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
- name: Build frontend report for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
run: pnpm --filter frontend run build
- name: Install dependencies for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
run: pnpm --filter "frontend^..." run build
- name: Build frontend report for pull request
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: after
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
run: pnpm --filter frontend run build
- name: Upload bundle visualizer
if: steps.check-base-visualizer.outputs.supported == 'true'
id: upload-bundle-visualizer
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-visualizer
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate report markdown
if: steps.check-base-visualizer.outputs.supported == 'true'
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/frontend-js-size-report.md"
node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-visualizer-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check report
if: steps.check-base-visualizer.outputs.supported == 'true'
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
test -s "$REPORT_DIR/before-stats.json"
test -s "$REPORT_DIR/after-stats.json"
test -s "$REPORT_DIR/frontend-js-size-report.md"
test -s "$REPORT_DIR/frontend-bundle-visualizer-report.md"
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
printf '\n\n' >> "$GITHUB_STEP_SUMMARY"
cat "$REPORT_DIR/frontend-bundle-visualizer-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-report

View file

@ -9,14 +9,7 @@ on:
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/scripts/utility.mts
- .github/scripts/backend-memory-report.mts
- .github/scripts/measure-backend-memory-comparison.mts
- .github/scripts/backend-js-footprint.mjs
- .github/scripts/backend-js-footprint-loader.mjs
- .github/scripts/backend-js-footprint-require.cjs
- .github/workflows/get-backend-memory.yml
- .github/workflows/report-backend-memory.yml
jobs:
get-memory-usage:
@ -24,6 +17,15 @@ jobs:
permissions:
contents: read
strategy:
matrix:
memory-json-name: [memory-base.json, memory-head.json]
include:
- memory-json-name: memory-base.json
ref: ${{ github.base_ref }}
- memory-json-name: memory-head.json
ref: refs/pull/${{ github.event.number }}/merge
services:
postgres:
image: postgres:18
@ -38,77 +40,37 @@ jobs:
- 56312:6379
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.2
with:
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: head/package.json
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: 'head/.node-version'
node-version-file: '.node-version'
cache: 'pnpm'
cache-dependency-path: |
base/pnpm-lock.yaml
head/pnpm-lock.yaml
- name: Install base dependencies
working-directory: base
run: pnpm i --frozen-lockfile
- name: Check base pnpm-lock.yaml
working-directory: base
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Configure base
working-directory: base
run: |
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build base
working-directory: base
- name: Copy Configure
run: cp .github/misskey/test.yml .config/default.yml
- name: Compile Configure
run: pnpm compile-config
- name: Build
run: pnpm build
- name: Install head dependencies
working-directory: head
run: pnpm i --frozen-lockfile
- name: Check head pnpm-lock.yaml
working-directory: head
run: git diff --exit-code pnpm-lock.yaml
- name: Configure head
working-directory: head
- name: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
run: |
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build head
working-directory: head
run: pnpm build
- name: Measure backend memory usage
env:
MK_MEMORY_COMPARE_ROUNDS: 5
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
MK_MEMORY_HEAP_SNAPSHOT: 1
run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json
- name: Measure backend loaded JS footprint
run: |
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json
node head/.github/scripts/backend-js-footprint.mjs head js-footprint-head.json
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: memory-artifact-results
path: |
memory-base.json
memory-head.json
js-footprint-base.json
js-footprint-head.json
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
save-pr-number:
runs-on: ubuntu-latest

View file

@ -11,14 +11,9 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Download artifact
uses: actions/github-script@v9
with:
@ -53,13 +48,120 @@ jobs:
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Output base JS footprint
run: cat ./artifacts/js-footprint-base.json
- name: Output head JS footprint
run: cat ./artifacts/js-footprint-head.json
- name: Compare memory usage
id: compare
run: |
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
variation() {
calc() {
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0")
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0")
DIFF=$((HEAD - BASE))
if [ "$BASE" -gt 0 ]; then
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
else
DIFF_PERCENT=0
fi
# Convert KB to MB for readability
BASE_MB=$(echo "scale=2; $BASE / 1024" | bc)
HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc)
JSON=$(jq -c -n \
--argjson base "$BASE_MB" \
--argjson head "$HEAD_MB" \
--argjson diff "$DIFF_MB" \
--argjson diff_percent "$DIFF_PERCENT" \
'{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson VmRSS "$(calc $1 VmRSS)" \
--argjson VmHWM "$(calc $1 VmHWM)" \
--argjson VmSize "$(calc $1 VmSize)" \
--argjson VmData "$(calc $1 VmData)" \
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson beforeGc "$(variation beforeGc)" \
--argjson afterGc "$(variation afterGc)" \
--argjson afterRequest "$(variation afterRequest)" \
'{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}')
echo "res=$JSON" >> "$GITHUB_OUTPUT"
- id: build-comment
name: Build memory comment
run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
env:
RES: ${{ steps.compare.outputs.res }}
run: |
HEADER="## Backend memory usage comparison"
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
echo "$HEADER" > ./output.md
echo >> ./output.md
table() {
echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md
echo "|--------|------:|------:|------:|------:|" >> ./output.md
line() {
METRIC=$2
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff")
DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent")
if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then
DIFF="+$DIFF"
DIFF_PERCENT="+$DIFF_PERCENT"
fi
# highlight VmRSS
if [ "$2" = "VmRSS" ]; then
METRIC="**${METRIC}**"
BASE="**${BASE}**"
HEAD="**${HEAD}**"
DIFF="**${DIFF}**"
DIFF_PERCENT="**${DIFF_PERCENT}**"
fi
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
}
line $1 VmRSS
line $1 VmHWM
line $1 VmSize
line $1 VmData
}
echo "### Before GC" >> ./output.md
table beforeGc
echo >> ./output.md
echo "### After GC" >> ./output.md
table afterGc
echo >> ./output.md
echo "### After Request" >> ./output.md
table afterRequest
echo >> ./output.md
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi
echo "$FOOTER" >> ./output.md
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}

View file

@ -19,6 +19,12 @@ on:
- .github/workflows/test-backend.yml
- .github/misskey/test.yml
workflow_dispatch:
inputs:
force_ffmpeg_cache_update:
description: 'Force update ffmpeg cache'
required: false
default: false
type: boolean
jobs:
unit:
@ -56,9 +62,36 @@ jobs:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
- name: Get current date
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Setup and Restore ffmpeg/ffprobe Cache
id: cache-ffmpeg
uses: actions/cache@v5
with:
path: |
/usr/local/bin/ffmpeg
/usr/local/bin/ffprobe
# daily cache
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
restore-keys: |
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
- name: Install FFmpeg
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
run: |
sudo apt install -y ffmpeg
for i in {1..3}; do
echo "Attempt $i: Installing FFmpeg..."
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
tar -xf ffmpeg.tar.xz && \
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
break || sleep 10
if [ $i -eq 3 ]; then
echo "Failed to install FFmpeg after 3 attempts"
exit 1
fi
done
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View file

@ -15,6 +15,12 @@ on:
- packages/misskey-js/**
- .github/workflows/test-federation.yml
workflow_dispatch:
inputs:
force_ffmpeg_cache_update:
description: 'Force update ffmpeg cache'
required: false
default: false
type: boolean
jobs:
test:
@ -31,9 +37,36 @@ jobs:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
- name: Get current date
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Setup and Restore ffmpeg/ffprobe Cache
id: cache-ffmpeg
uses: actions/cache@v5
with:
path: |
/usr/local/bin/ffmpeg
/usr/local/bin/ffprobe
# daily cache
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
restore-keys: |
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
- name: Install FFmpeg
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
run: |
sudo apt install -y ffmpeg
for i in {1..3}; do
echo "Attempt $i: Installing FFmpeg..."
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
tar -xf ffmpeg.tar.xz && \
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
break || sleep 10
if [ $i -eq 3 ]; then
echo "Failed to install FFmpeg after 3 attempts"
exit 1
fi
done
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View file

@ -1 +1 @@
22.18.0
22.15.0

View file

@ -1,15 +1,3 @@
## Unreleased
### General
-
### Client
-
### Server
-
## 2026.6.0
### General
@ -46,7 +34,6 @@
- Fix: `actor` を持たない不正なInboxアクティビティを受信した際に配送ジョブが `TypeError` でクラッシュする問題を修正 (受信時に検証して400で返し、ジョブを積まないように変更)
- Fix: Startup and shutdown failures (port-in-use, socket permission denied, plugin timeouts, leaked WebSocket connections) are now reported through the misskey logger instead of an UnhandledPromiseRejectionWarning stack trace
- Fix: リモートのノートに対するメンション数制限が、サーバーが解決できたユーザー数ベースで行われていた問題を修正
- Fix: セキュリティに関する修正
## 2026.5.4

View file

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.6.0",
"version": "2026.6.0-beta.1",
"codename": "nasubi",
"repository": {
"type": "git",

View file

@ -118,7 +118,7 @@
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.1.0",
"nodemailer": "8.0.10",
"nodemailer": "9.0.1",
"nsfwjs": "4.3.0",
"os-utils": "0.0.14",
"otpauth": "9.5.1",

View file

@ -0,0 +1,231 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* This script starts the Misskey backend server, waits for it to be ready,
* measures memory usage, and outputs the result as JSON.
*
* Usage: node scripts/measure-memory.mjs
*/
import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import * as http from 'node:http';
import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SAMPLE_COUNT = 3; // Number of samples to measure
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
const keys = {
VmPeak: 0,
VmSize: 0,
VmHWM: 0,
VmRSS: 0,
VmData: 0,
VmStk: 0,
VmExe: 0,
VmLib: 0,
VmPTE: 0,
VmSwap: 0,
};
async function getMemoryUsage(pid) {
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const result = {};
for (const key of Object.keys(keys)) {
const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) {
result[key] = parseInt(match[1], 10);
} else {
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
}
}
return result;
}
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/entry.js'), ['expose-gc'], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: [...process.execArgv, '--expose-gc'],
});
let serverReady = false;
// Listen for the 'ok' message from the server indicating it's ready
serverProcess.on('message', (message) => {
if (message === 'ok') {
serverReady = true;
}
});
// Handle server output
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
async function triggerGc() {
const ok = new Promise((resolve) => {
serverProcess.once('message', (message) => {
if (message === 'gc ok') resolve();
});
});
serverProcess.send('gc');
await ok;
await setTimeout(1000);
}
function createRequest() {
return new Promise((resolve, reject) => {
const req = http.request({
host: 'localhost',
port: 61812,
path: '/api/meta',
method: 'POST',
}, (res) => {
res.on('data', () => { });
res.on('end', () => {
resolve();
});
});
req.on('error', (err) => {
reject(err);
});
req.end();
});
}
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
const pid = serverProcess.pid;
const beforeGc = await getMemoryUsage(pid);
await triggerGc();
const afterGc = await getMemoryUsage(pid);
// create some http requests to simulate load
const REQUEST_COUNT = 10;
await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
);
await triggerGc();
const afterRequest = await getMemoryUsage(pid);
// Stop the server
serverProcess.kill('SIGTERM');
// Wait for process to exit
let exited = false;
await new Promise((resolve) => {
serverProcess.on('exit', () => {
exited = true;
resolve(undefined);
});
// Force kill after 10 seconds if not exited
setTimeout(10000).then(() => {
if (!exited) {
serverProcess.kill('SIGKILL');
}
resolve(undefined);
});
});
const result = {
timestamp: new Date().toISOString(),
beforeGc,
afterGc,
afterRequest,
};
return result;
}
async function main() {
// 直列の方が時間的に分散されて正確そうだから直列でやる
const results = [];
for (let i = 0; i < SAMPLE_COUNT; i++) {
const res = await measureMemory();
results.push(res);
}
// Calculate averages
const beforeGc = structuredClone(keys);
const afterGc = structuredClone(keys);
const afterRequest = structuredClone(keys);
for (const res of results) {
for (const key of Object.keys(keys)) {
beforeGc[key] += res.beforeGc[key];
afterGc[key] += res.afterGc[key];
afterRequest[key] += res.afterRequest[key];
}
}
for (const key of Object.keys(keys)) {
beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
}
const result = {
timestamp: new Date().toISOString(),
beforeGc,
afterGc,
afterRequest,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
main().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
}));
process.exit(1);
});

View file

@ -1,545 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ChildProcess, fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
//import * as http from 'node:http';
import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function readIntegerEnv(name, defaultValue, min) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
function readBooleanEnv(name, defaultValue) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (rawValue === '1' || rawValue === 'true') return true;
if (rawValue === '0' || rawValue === 'false') return false;
throw new Error(`${name} must be one of: 1, 0, true, false`);
}
const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
const heapSnapshotCategories = [
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
'Total',
];
const typedArrayNames = new Set([
'ArrayBuffer',
'SharedArrayBuffer',
'DataView',
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float16Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array',
'system / JSArrayBufferData',
]);
const otherJsNodeTypes = new Set([
'object',
'closure',
'regexp',
'number',
'symbol',
'bigint',
]);
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
const result = {} as Record<KS[number], number>;
for (const _key of keys) {
const key = _key as KS[number];
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) {
result[key] = parseInt(match[1], 10);
} else if (required) {
throw new Error(`Failed to parse ${key} from ${path}`);
}
}
return result;
}
function bytesToKiB(value: number) {
return Math.round(value / 1024);
}
function createEmptyHeapSnapshotCategoryMap() {
return Object.fromEntries(heapSnapshotCategories.map(category => [category, 0]));
}
function isTypedArrayNode(type, name) {
return typedArrayNames.has(name) ||
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
}
function isSystemNode(type, name) {
return type === 'hidden' ||
type === 'synthetic' ||
type === 'object shape' ||
name.startsWith('system /') ||
name.startsWith('(system ');
}
function classifyHeapSnapshotNode(type, name) {
if (type === 'code') return 'Code';
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'Strings';
if (isTypedArrayNode(type, name)) return 'Typed arrays';
if (type === 'array' || (type === 'object' && name === 'Array')) return 'JS arrays';
if (isSystemNode(type, name)) return 'System objects';
if (otherJsNodeTypes.has(type)) return 'Other JS objects';
return 'Other non-JS objects';
}
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
if (label.length <= 80) return label;
return `${label.slice(0, 77)}...`;
}
function classifyHeapSnapshotBreakdown(category, type, name) {
if (category === 'Strings') return type;
if (category === 'JS arrays') {
if (type === 'array') return 'array nodes';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'Typed arrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
if (typedArrayNames.has(name)) return name;
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'System objects') {
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'Other JS objects') {
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
return type;
}
if (category === 'Other non-JS objects') {
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'Code') {
const lowerName = name.toLowerCase();
if (lowerName.includes('bytecode')) return 'bytecode';
if (lowerName.includes('builtin')) return 'builtins';
if (lowerName.includes('regexp')) return 'regexp code';
if (lowerName.includes('stub')) return 'stubs';
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
}
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
function collapseHeapSnapshotBreakdown(breakdowns) {
const collapsed = {};
for (const [category, children] of Object.entries(breakdowns)) {
const entries = Object.entries(children)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const categoryBreakdown = Object.fromEntries(topEntries);
if (otherValue > 0) categoryBreakdown.Other = otherValue;
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
}
return collapsed;
}
function analyzeHeapSnapshot(snapshot) {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) {
throw new Error('Invalid heap snapshot format');
}
const nodeFields = meta.node_fields;
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const fieldCount = nodeFields.length;
const categories = createEmptyHeapSnapshotCategoryMap();
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
const breakdowns = Object.fromEntries(
heapSnapshotCategories
.filter(category => category !== 'Total')
.map(category => [category, {}]),
);
function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
const name = strings[nodes[offset + nameOffset]] ?? '';
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
const category = classifyHeapSnapshotNode(type, name);
categories[category] += selfSize;
categories.Total += selfSize;
nodeCounts[category]++;
nodeCounts.Total++;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
}
return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
};
}
async function getMemoryUsage(pid: number) {
const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8');
return parseMemoryFile(status, procStatusKeys, path, true);
}
async function getSmapsRollupMemoryUsage(pid: number) {
const path = `/proc/${pid}/smaps_rollup`;
const smapsRollup = await fs.readFile(path, 'utf-8');
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
}
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
return new Promise((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
serverProcess.off('message', onMessage);
reject(new Error(`Timed out waiting for ${description}`));
}, timeout);
const onMessage = (message) => {
if (!predicate(message)) return;
globalThis.clearTimeout(timer);
serverProcess.off('message', onMessage);
resolve(message);
};
serverProcess.on('message', onMessage);
});
}
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
const response = waitForMessage(
serverProcess,
message => message != null && typeof message === 'object' && message.type === 'memory usage',
'memory usage',
);
serverProcess.send('memory usage');
const message = await response;
const memoryUsage = message.value;
return {
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
External: bytesToKiB(memoryUsage.external),
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
};
}
async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
if (!HEAP_SNAPSHOT) return null;
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
const response = waitForMessage(
serverProcess,
message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'),
'heap snapshot',
HEAP_SNAPSHOT_TIMEOUT,
);
serverProcess.send({
type: 'heap snapshot',
path: snapshotPath,
});
const message = await response;
if (message.type === 'heap snapshot error') {
throw new Error(`Failed to write heap snapshot: ${message.message}`);
}
const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
try {
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
return analyzeHeapSnapshot(snapshot);
} finally {
await fs.unlink(writtenPath).catch(err => {
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
});
}
}
async function getAllMemoryUsage(serverProcess: ChildProcess) {
const pid = serverProcess.pid!;
return {
...await getMemoryUsage(pid),
...await getSmapsRollupMemoryUsage(pid),
...await getRuntimeMemoryUsage(serverProcess),
};
}
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_ONLY_SERVER: '1',
MK_NO_DAEMONS: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: [...process.execArgv, '--expose-gc'],
});
let serverReady = false;
// Listen for the 'ok' message from the server indicating it's ready
serverProcess.on('message', (message) => {
if (message === 'ok') {
serverReady = true;
}
});
// Handle server output
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
async function triggerGc() {
const ok = waitForMessage(
serverProcess,
message => message === 'gc ok' || message === 'gc unavailable',
'GC completion',
);
serverProcess.send('gc');
const message = await ok;
if (message === 'gc unavailable') {
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
}
await setTimeout(1000);
}
//function createRequest() {
// return new Promise((resolve, reject) => {
// const req = http.request({
// host: 'localhost',
// port: 61812,
// path: '/api/meta',
// method: 'POST',
// }, (res) => {
// res.on('data', () => { });
// res.on('end', () => {
// resolve();
// });
// });
// req.on('error', (err) => {
// reject(err);
// });
// req.end();
// });
//}
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
//const beforeGc = await getAllMemoryUsage(serverProcess);
await triggerGc();
const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
//// create some http requests to simulate load
//await Promise.all(
// Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
//);
//await triggerGc();
//const afterRequest = await getAllMemoryUsage(serverProcess);
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
// Stop the server
serverProcess.kill('SIGTERM');
// Wait for process to exit
let exited = false;
await new Promise((resolve) => {
serverProcess.on('exit', () => {
exited = true;
resolve(undefined);
});
// Force kill after 10 seconds if not exited
setTimeout(10000).then(() => {
if (!exited) {
serverProcess.kill('SIGKILL');
}
resolve(undefined);
});
});
const result = {
timestamp: new Date().toISOString(),
phases: {
//beforeGc,
afterGc: {
memoryUsage: memoryUsageAfterGC,
heapSnapshot: heapSnapshotAfterGc,
},
//afterRequest,
},
};
return result;
}
export type MemoryReportRaw = {
timestamp: string;
sampleCount: number;
measurement: {
startupTimeoutMs: number;
memorySettleTimeMs: number;
ipcTimeoutMs: number;
requestCount: number;
heapSnapshot: {
enabled: boolean;
timeoutMs: number;
breakdownTopN: number;
};
};
samples: Awaited<ReturnType<typeof measureMemory>>[];
};
async function main() {
const results = [];
for (let i = 0; i < SAMPLE_COUNT; i++) {
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
const res = await measureMemory();
results.push(res);
}
const result: MemoryReportRaw = {
timestamp: new Date().toISOString(),
sampleCount: SAMPLE_COUNT,
measurement: {
startupTimeoutMs: STARTUP_TIMEOUT,
memorySettleTimeMs: MEMORY_SETTLE_TIME,
ipcTimeoutMs: IPC_TIMEOUT,
requestCount: REQUEST_COUNT,
heapSnapshot: {
enabled: HEAP_SNAPSHOT,
timeoutMs: HEAP_SNAPSHOT_TIMEOUT,
breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
},
},
samples: results,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
main().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
}));
process.exit(1);
});

View file

@ -6,7 +6,6 @@
import { NestFactory } from '@nestjs/core';
import { init } from 'slacc';
import { NestLogger } from '@/NestLogger.js';
import { envOption } from '@/env.js';
import type { Config } from '@/config.js';
let slaccInitialized = false;
@ -32,7 +31,7 @@ export async function server() {
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test' && !envOption.noDaemons) {
if (process.env.NODE_ENV !== 'test') {
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
@ -55,9 +54,7 @@ export async function jobQueue() {
});
jobQueue.get(QueueProcessorService).start();
if (!envOption.noDaemons) {
jobQueue.get(ChartManagementService).start();
}
jobQueue.get(ChartManagementService).start();
return jobQueue;
}

View file

@ -9,7 +9,6 @@
import cluster from 'node:cluster';
import { EventEmitter } from 'node:events';
import { writeHeapSnapshot } from 'node:v8';
import chalk from 'chalk';
import Xev from 'xev';
import Logger from '@/logger.js';
@ -92,35 +91,10 @@ process.on('message', msg => {
if (msg === 'gc') {
if (global.gc != null) {
logger.info('Manual GC triggered');
for (let i = 0; i < 3; i++) {
global.gc();
}
global.gc();
if (process.send != null) process.send('gc ok');
} else {
logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.');
if (process.send != null) process.send('gc unavailable');
}
} else if (msg === 'memory usage') {
if (process.send != null) {
process.send({
type: 'memory usage',
value: process.memoryUsage(),
});
}
} else if (msg != null && typeof msg === 'object' && 'type' in msg && msg.type === 'heap snapshot' && 'path' in msg && typeof msg.path === 'string') {
if (process.send != null) {
try {
const path = writeHeapSnapshot(msg.path);
process.send({
type: 'heap snapshot',
path,
});
} catch (err) {
process.send({
type: 'heap snapshot error',
message: err instanceof Error ? err.message : String(err),
});
}
}
}
});

View file

@ -6,7 +6,6 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as OTPAuth from 'otpauth';
import { createHash } from 'node:crypto';
import { DI } from '@/di-symbols.js';
import type { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
@ -32,58 +31,43 @@ export class UserAuthService {
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
});
} else {
if (!await this.validateOtp(profile.userId, profile.twoFactorSecret!, token)) {
// 1. 判定に用いるタイムスタンプを固定
const now = Date.now();
const normalizedToken = token.trim();
const validationWindow = 1;
const timeStep = 30; // TOTPの周期
// 2. TOTPインスタンスを生成設定を一元管理するため
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
digits: 6,
period: timeStep,
});
// 3. 固定したタイムスタンプを使って検証
const delta = totp.validate({
token: normalizedToken,
window: validationWindow,
timestamp: now,
});
if (delta === null) {
throw new Error('authentication failed');
}
// 4. totp.counter() を用い、同じタイムスタンプから基準ステップを取得
const currentStep = totp.counter({ timestamp: now });
const step = currentStep + delta;
const usedTokenRedisKey = `2fa:used:${profile.userId}:${step}`;
// 5. TTL有効期限の設定
const ttl = timeStep * (validationWindow * 2 + 1);
const setResult = await this.redisClient.set(usedTokenRedisKey, normalizedToken, 'EX', ttl, 'NX');
if (setResult === null) {
throw new Error('authentication failed');
}
}
}
public async validateOtp(
userId: MiUserProfile['userId'],
twoFactorSecret: string,
token: string,
) {
if (process.env.NODE_ENV === 'test' && process.env.MISSKEY_TEST_CHECK_DUPLICATED_TOTP !== '1') {
return true;
}
// 1. 判定に用いるタイムスタンプを固定
const now = Date.now();
const normalizedToken = token.trim();
const validationWindow = 1;
const timeStep = 30; // TOTPの周期
// 2. TOTPインスタンスを生成設定を一元管理するため
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(twoFactorSecret),
digits: 6,
period: timeStep,
});
// 3. 固定したタイムスタンプを使って検証
const delta = totp.validate({
token: normalizedToken,
window: validationWindow,
timestamp: now,
});
if (delta === null) {
throw new Error('authentication failed');
}
// 4. totp.counter() を用い、同じタイムスタンプから基準ステップを取得
const currentStep = totp.counter({ timestamp: now });
const step = currentStep + delta;
const secretFingerprint = createHash('sha256')
.update(twoFactorSecret ?? '')
.digest('base64url');
const usedTokenRedisKey = `2fa:used:${userId}:${secretFingerprint}:${step}`;
// 5. TTL有効期限を設定いてredis set
const ttl = timeStep * (validationWindow * 2 + 1);
const setResult = await this.redisClient.set(usedTokenRedisKey, normalizedToken, 'EX', ttl, 'NX');
return setResult === 'OK';
}
}

View file

@ -10,7 +10,6 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/_.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { UserAuthService } from "@/core/UserAuthService.js";
export const meta = {
requireCredential: true,
@ -46,7 +45,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private userAuthService: UserAuthService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
@ -58,7 +56,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('二段階認証の設定が開始されていません');
}
if (!await this.userAuthService.validateOtp(profile.userId, profile.twoFactorTempSecret, token)) {
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
digits: 6,
token,
window: 5,
});
if (delta === null) {
throw new Error('not verified');
}

View file

@ -10,7 +10,7 @@ import * as crypto from 'node:crypto';
import cbor from 'cbor';
import * as OTPAuth from 'otpauth';
import { loadConfig } from '@/config.js';
import { api, signup, sendEnvUpdateRequest } from '../utils.js';
import { api, signup } from '../utils.js';
import type {
AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON,
@ -20,7 +20,7 @@ import type {
RegistrationResponseJSON,
} from '@simplewebauthn/server';
import type * as misskey from 'misskey-js';
import { describe, beforeAll, beforeEach, test } from 'vitest';
import { describe, beforeAll, test } from 'vitest';
describe('2要素認証', () => {
let alice: misskey.entities.SignupResponse;
@ -181,10 +181,6 @@ describe('2要素認証', () => {
alice = await signup({ username, password });
}, 1000 * 60 * 2);
beforeEach(async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '' });
});
test('が設定でき、OTPでログインできる。', async () => {
const registerResponse = await api('i/2fa/register', {
password,
@ -491,33 +487,4 @@ describe('2要素認証', () => {
token: otpToken(registerResponse.body.secret),
}, alice);
});
test('のTOTPトークンは一度使うと同じトークンは再利用できない。', async () => {
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '1' });
const registerResponse = await api('i/2fa/register', {
password,
}, alice);
assert.strictEqual(registerResponse.status, 200);
const sharedOtpToken = otpToken(registerResponse.body.secret);
const doneResponse = await api('i/2fa/done', {
token: sharedOtpToken,
}, alice);
assert.strictEqual(doneResponse.status, 200);
const signinResponse = await api('signin-flow', {
...signinParam(),
token: sharedOtpToken,
});
assert.strictEqual(signinResponse.status, 403);
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_DUPLICATED_TOTP', value: '' });
// 後片付け
await api('i/2fa/unregister', {
password,
token: otpToken(registerResponse.body.secret),
}, alice);
});
});

View file

@ -27,29 +27,27 @@ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.s
function getBundleVisualizerPlugin(): PluginOption[] {
if (process.env.FRONTEND_BUNDLE_VISUALIZER !== 'true') return [];
const visualizerOptions = {
title: 'Misskey frontend bundle visualizer',
gzipSize: true,
brotliSize: true,
projectRoot: path.resolve(__dirname, '../..'),
};
const plugins = [
const template = process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'markdown'
? 'markdown'
: process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'raw-data'
? 'raw-data'
: 'treemap';
const defaultFilename = template === 'markdown'
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/report.md')
: template === 'raw-data'
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.json')
: path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.html');
return [
visualizer({
...visualizerOptions,
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE,
template: 'raw-data',
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE ?? defaultFilename,
title: 'Misskey frontend bundle visualizer',
template,
gzipSize: true,
brotliSize: true,
projectRoot: path.resolve(__dirname, '../..'),
}) as PluginOption,
];
if (process.env.FRONTEND_BUNDLE_VISUALIZER_HTML_FILE != null) {
plugins.push(visualizer({
...visualizerOptions,
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_HTML_FILE,
template: 'treemap',
}) as PluginOption);
}
return plugins;
}
/**

View file

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.6.0",
"version": "2026.6.0-beta.1",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

441
pnpm-lock.yaml generated
View file

@ -44,7 +44,7 @@ importers:
version: 9.39.4
'@misskey-dev/eslint-plugin':
specifier: 2.1.0
version: 2.1.0(@eslint/compat@2.0.5(eslint@9.39.4))(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4)(globals@17.6.0)
version: 2.1.0(@eslint/compat@2.0.5(eslint@9.39.4(supports-color@5.5.0)))(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(supports-color@5.5.0)))(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0)))(eslint@9.39.4(supports-color@5.5.0))(globals@17.6.0)
'@types/js-yaml':
specifier: 4.0.9
version: 4.0.9
@ -53,10 +53,10 @@ importers:
version: 24.13.1
'@typescript-eslint/eslint-plugin':
specifier: 8.61.0
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: 8.61.0
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
version: 8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
'@typescript/native-preview':
specifier: 7.0.0-dev.20260426.1
version: 7.0.0-dev.20260426.1
@ -68,7 +68,7 @@ importers:
version: 15.17.0
eslint:
specifier: 9.39.4
version: 9.39.4
version: 9.39.4(supports-color@5.5.0)
globals:
specifier: 17.6.0
version: 17.6.0
@ -80,7 +80,7 @@ importers:
version: 11.5.2
start-server-and-test:
specifier: 3.0.9
version: 3.0.9
version: 3.0.9(supports-color@5.5.0)
typescript:
specifier: 5.9.3
version: 5.9.3
@ -287,8 +287,8 @@ importers:
specifier: 7.1.0
version: 7.1.0
nodemailer:
specifier: 8.0.10
version: 8.0.10
specifier: 9.0.1
version: 9.0.1
nsfwjs:
specifier: 4.3.0
version: 4.3.0(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(buffer@6.0.3)
@ -493,7 +493,7 @@ importers:
version: 10.1.0
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
execa:
specifier: 9.6.1
version: 9.6.1
@ -846,10 +846,10 @@ importers:
version: 1.4.6
'@typescript-eslint/eslint-plugin':
specifier: 8.61.0
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: 8.61.0
version: 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: 4.1.8
version: 4.1.8(vitest@4.1.8)
@ -870,10 +870,10 @@ importers:
version: 15.17.0
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
eslint-plugin-vue:
specifier: 10.9.2
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2))
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
happy-dom:
specifier: 20.10.2
version: 20.10.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
@ -924,7 +924,7 @@ importers:
version: 3.0.5
start-server-and-test:
specifier: 3.0.9
version: 3.0.9(supports-color@10.2.2)
version: 3.0.9
storybook:
specifier: 10.4.3
version: 10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6)
@ -1094,7 +1094,7 @@ importers:
version: 10.1.0
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
eslint-plugin-vue:
specifier: 10.9.2
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
@ -1136,7 +1136,7 @@ importers:
version: 3.3.4
vue-eslint-parser:
specifier: 10.4.1
version: 10.4.1(eslint@9.39.4)
version: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
vue-tsc:
specifier: 3.3.4
version: 3.3.4(typescript@5.9.3)
@ -1182,7 +1182,7 @@ importers:
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
vue-eslint-parser:
specifier: 10.4.1
version: 10.4.1(eslint@9.39.4)
version: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
packages/i18n:
dependencies:
@ -1410,7 +1410,7 @@ importers:
version: '@types/serviceworker@0.0.74'
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
nodemon:
specifier: 3.1.14
version: 3.1.14
@ -4546,7 +4546,7 @@ packages:
engines: {node: '>= 14'}
aiscript-vscode@https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67:
resolution: {gitHosted: true, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67}
resolution: {gitHosted: true, integrity: sha512-S4eSTHasZz29AMlnU2/zdGP8zikiDiYfYW9kNooAfwVo8OghXdxKuTDDKDAjWsbBxa1+P4uQHa4BNk9MY74rJQ==, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67}
version: 0.1.16
engines: {vscode: ^1.83.0}
@ -7564,8 +7564,8 @@ packages:
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nodemailer@8.0.10:
resolution: {integrity: sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==}
nodemailer@9.0.1:
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
engines: {node: '>=6.0.0'}
nodemon@3.1.14:
@ -9074,7 +9074,7 @@ packages:
engines: {node: '>= 0.4'}
storybook-addon-misskey-theme@https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640:
resolution: {gitHosted: true, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640}
resolution: {gitHosted: true, integrity: sha512-QaH1uZSlApQ2CZPkHfhmNm89I92L02s3MdbUPG66TmAyqMaqzxd/AvobORBjtTZ0ymUSa3ii482dRXi+fFb19w==, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640}
version: 0.0.0
peerDependencies:
'@storybook/blocks': ^7.0.0-rc.4
@ -10480,32 +10480,12 @@ snapshots:
'@babel/compat-data@7.29.3': {}
'@babel/core@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/core@7.29.0(supports-color@10.2.2)':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
@ -10545,7 +10525,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@10.2.2))':
dependencies:
'@babel/core': 7.29.0(supports-color@10.2.2)
'@babel/helper-module-imports': 7.28.6
@ -10587,7 +10567,7 @@ snapshots:
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@ -10897,6 +10877,11 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(supports-color@5.5.0))':
dependencies:
eslint: 9.39.4(supports-color@5.5.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
dependencies:
eslint: 9.39.4
@ -10904,13 +10889,21 @@ snapshots:
'@eslint-community/regexpp@4.12.2': {}
'@eslint/compat@2.0.5(eslint@9.39.4)':
'@eslint/compat@2.0.5(eslint@9.39.4(supports-color@5.5.0))':
dependencies:
'@eslint/core': 1.2.1
optionalDependencies:
eslint: 9.39.4
eslint: 9.39.4(supports-color@5.5.0)
'@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@10.2.2)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
'@eslint/config-array@0.21.2(supports-color@5.5.0)':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@5.5.0)
@ -10931,6 +10924,20 @@ snapshots:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@10.2.2)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.2.0
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
'@eslint/eslintrc@3.3.5(supports-color@5.5.0)':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@5.5.0)
@ -11340,14 +11347,14 @@ snapshots:
'@misskey-dev/emoji-data@17.0.3': {}
'@misskey-dev/eslint-plugin@2.1.0(@eslint/compat@2.0.5(eslint@9.39.4))(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4)(globals@17.6.0)':
'@misskey-dev/eslint-plugin@2.1.0(@eslint/compat@2.0.5(eslint@9.39.4(supports-color@5.5.0)))(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(supports-color@5.5.0)))(@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0)))(eslint@9.39.4(supports-color@5.5.0))(globals@17.6.0)':
dependencies:
'@eslint/compat': 2.0.5(eslint@9.39.4)
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4)
'@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
eslint: 9.39.4
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
'@eslint/compat': 2.0.5(eslint@9.39.4(supports-color@5.5.0))
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(supports-color@5.5.0))
'@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
eslint: 9.39.4(supports-color@5.5.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0))
globals: 17.6.0
'@misskey-dev/sharp-read-bmp@1.2.0':
@ -11542,15 +11549,6 @@ snapshots:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/api-logs': 0.214.0
import-in-the-middle: 3.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)':
dependencies:
'@opentelemetry/api': 1.9.1
@ -12170,22 +12168,6 @@ snapshots:
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry/node@10.57.0':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry-internal/server-utils': 10.57.0
'@sentry/core': 10.57.0
'@sentry/node-core': 10.57.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)
'@sentry/opentelemetry': 10.57.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)
import-in-the-middle: 3.0.1
transitivePeerDependencies:
- '@opentelemetry/exporter-trace-otlp-http'
- supports-color
'@sentry/node@10.57.0(supports-color@10.2.2)':
dependencies:
'@opentelemetry/api': 1.9.1
@ -12214,7 +12196,7 @@ snapshots:
dependencies:
'@sentry-internal/node-cpu-profiler': 2.4.0
'@sentry/core': 10.57.0
'@sentry/node': 10.57.0
'@sentry/node': 10.57.0(supports-color@10.2.2)
transitivePeerDependencies:
- '@opentelemetry/exporter-trace-otlp-http'
- supports-color
@ -12558,11 +12540,11 @@ snapshots:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.0.16(@types/node@24.13.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4))
'@rollup/pluginutils': 5.4.0(rollup@4.61.1)
'@storybook/builder-vite': 10.4.3(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6))(vite@8.0.16(@types/node@24.13.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4))
'@storybook/react': 10.4.3(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6))(typescript@5.9.3)
'@storybook/react': 10.4.3(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6))(supports-color@10.2.2)(typescript@5.9.3)
empathic: 2.0.0
magic-string: 0.30.21
react: 19.2.7
react-docgen: 8.0.3
react-docgen: 8.0.3(supports-color@10.2.2)
react-dom: 19.2.7(react@19.2.7)
resolve: 1.22.12
storybook: 10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6)
@ -12592,21 +12574,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@storybook/react@10.4.3(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6))(typescript@5.9.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 10.4.3(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6))
react: 19.2.7
react-docgen: 8.0.3
react-docgen-typescript: 2.4.0(typescript@5.9.3)
react-dom: 19.2.7(react@19.2.7)
storybook: 10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6)
optionalDependencies:
'@types/react': 19.2.14
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@storybook/source-loader@8.6.18(storybook@10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6))':
dependencies:
es-toolkit: 1.46.1
@ -12657,6 +12624,16 @@ snapshots:
vue: 3.5.35(typescript@5.9.3)
vue-component-type-helpers: 3.3.4
'@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(supports-color@5.5.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@5.5.0))
'@typescript-eslint/types': 8.61.0
eslint: 9.39.4(supports-color@5.5.0)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.4
'@stylistic/eslint-plugin@5.10.0(eslint@9.39.4)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@ -12666,6 +12643,7 @@ snapshots:
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.4
optional: true
'@syuilo/aiscript@0.19.0':
dependencies:
@ -12826,7 +12804,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@10.2.2)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@ -13077,15 +13055,15 @@ snapshots:
dependencies:
'@types/node': 24.13.1
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.61.0
eslint: 9.39.4
eslint: 9.39.4(supports-color@5.5.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@ -13109,14 +13087,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.4(supports-color@5.5.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13127,17 +13105,8 @@ snapshots:
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.61.0(supports-color@10.2.2)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/types': 8.61.0
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13146,7 +13115,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/types': 8.61.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@10.2.2)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13160,13 +13129,13 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(typescript@5.9.3)
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.4(supports-color@5.5.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@ -13177,7 +13146,7 @@ snapshots:
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@ -13186,9 +13155,9 @@ snapshots:
'@typescript-eslint/types@8.61.0': {}
'@typescript-eslint/typescript-estree@8.61.0(supports-color@10.2.2)(typescript@5.9.3)':
'@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.61.0(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/project-service': 8.61.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/visitor-keys': 8.61.0
@ -13201,17 +13170,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)':
'@typescript-eslint/utils@8.61.0(eslint@9.39.4(supports-color@5.5.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.61.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@5.5.0))
'@typescript-eslint/scope-manager': 8.61.0
'@typescript-eslint/types': 8.61.0
'@typescript-eslint/visitor-keys': 8.61.0
debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5
semver: 7.8.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
eslint: 9.39.4(supports-color@5.5.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13810,9 +13775,9 @@ snapshots:
aws4@1.13.2: {}
axios@1.16.0(debug@4.4.3(supports-color@10.2.2)):
axios@1.16.0(debug@4.4.3(supports-color@5.5.0)):
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2))
follow-redirects: 1.16.0(debug@4.4.3(supports-color@5.5.0))
form-data: 4.0.6
proxy-from-env: 2.1.0
transitivePeerDependencies:
@ -14935,7 +14900,7 @@ snapshots:
string-width: 4.2.3
supports-hyperlinks: 2.3.0
eslint-import-resolver-node@0.3.10(supports-color@8.1.1):
eslint-import-resolver-node@0.3.10:
dependencies:
debug: 3.2.7(supports-color@8.1.1)
is-core-module: 2.16.2
@ -14943,27 +14908,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(supports-color@5.5.0)):
dependencies:
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
eslint: 9.39.4(supports-color@5.5.0)
eslint-import-resolver-node: 0.3.10
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)(supports-color@8.1.1):
dependencies:
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
eslint-import-resolver-node: 0.3.10
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4(supports-color@5.5.0)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -14972,9 +14937,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7(supports-color@8.1.1)
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1)
eslint: 9.39.4(supports-color@5.5.0)
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(supports-color@5.5.0))
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@ -14986,13 +14951,13 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -15002,8 +14967,8 @@ snapshots:
debug: 3.2.7(supports-color@8.1.1)
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)(supports-color@8.1.1)
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@ -15021,20 +14986,6 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
eslint: 9.39.4
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 7.1.1
semver: 7.8.4
vue-eslint-parser: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
xml-name-validator: 4.0.0
optionalDependencies:
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4)
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@ -15043,7 +14994,7 @@ snapshots:
nth-check: 2.1.1
postcss-selector-parser: 7.1.1
semver: 7.8.4
vue-eslint-parser: 10.4.1(eslint@9.39.4)
vue-eslint-parser: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
xml-name-validator: 4.0.0
optionalDependencies:
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4)
@ -15086,6 +15037,45 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@10.2.2)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
transitivePeerDependencies:
- supports-color
eslint@9.39.4(supports-color@5.5.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@5.5.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2(supports-color@5.5.0)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5(supports-color@5.5.0)
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.9
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@5.5.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
@ -15438,13 +15428,13 @@ snapshots:
async: 0.2.10
which: 1.3.1
follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)):
follow-redirects@1.16.0(debug@4.4.3(supports-color@5.5.0)):
optionalDependencies:
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@5.5.0)
follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@10.2.2)
for-each@0.3.5:
dependencies:
@ -16498,23 +16488,6 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.3:
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
decode-named-character-reference: 1.3.0
devlop: 1.1.0
mdast-util-to-string: 4.0.0
micromark: 4.0.2
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-decode-string: 2.0.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
unist-util-stringify-position: 4.0.0
transitivePeerDependencies:
- supports-color
mdast-util-from-markdown@2.0.3(supports-color@10.2.2):
dependencies:
'@types/mdast': 4.0.4
@ -16544,7 +16517,7 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-from-markdown: 2.0.3(supports-color@10.2.2)
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
@ -16553,7 +16526,7 @@ snapshots:
mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-from-markdown: 2.0.3(supports-color@10.2.2)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
@ -16563,7 +16536,7 @@ snapshots:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-from-markdown: 2.0.3(supports-color@10.2.2)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
@ -16572,7 +16545,7 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-from-markdown: 2.0.3(supports-color@10.2.2)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
@ -16837,28 +16810,6 @@ snapshots:
micromark-util-types@2.0.2: {}
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
debug: 4.4.3(supports-color@5.5.0)
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-chunked: 2.0.1
micromark-util-combine-extensions: 2.0.1
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-encode: 2.0.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-subtokenize: 2.1.0
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
transitivePeerDependencies:
- supports-color
micromark@4.0.2(supports-color@10.2.2):
dependencies:
'@types/debug': 4.1.13
@ -17133,7 +17084,7 @@ snapshots:
node-releases@2.0.38: {}
nodemailer@8.0.10: {}
nodemailer@9.0.1: {}
nodemon@3.1.14:
dependencies:
@ -18010,21 +17961,6 @@ snapshots:
dependencies:
typescript: 5.9.3
react-docgen@8.0.3:
dependencies:
'@babel/core': 7.29.0
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
'@types/doctrine': 0.0.9
'@types/resolve': 1.20.6
doctrine: 3.0.0
resolve: 1.22.12
strip-indent: 4.1.1
transitivePeerDependencies:
- supports-color
react-docgen@8.0.3(supports-color@10.2.2):
dependencies:
'@babel/core': 7.29.0(supports-color@10.2.2)
@ -18167,7 +18103,7 @@ snapshots:
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-from-markdown: 2.0.3(supports-color@10.2.2)
micromark-util-types: 2.0.2
unified: 11.0.5
transitivePeerDependencies:
@ -18193,13 +18129,6 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@ -18502,22 +18431,6 @@ snapshots:
semver@7.8.4: {}
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.1
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
send@1.2.1(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@ -18539,7 +18452,7 @@ snapshots:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
send: 1.2.1(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@ -18804,7 +18717,7 @@ snapshots:
dependencies:
arg: 5.0.2
check-more-types: 2.24.0
debug: 4.4.3(supports-color@5.5.0)
debug: 4.4.3(supports-color@10.2.2)
execa: 5.1.1
lazy-ass: 2.0.3
tree-kill: 1.2.2
@ -18812,15 +18725,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
start-server-and-test@3.0.9(supports-color@10.2.2):
start-server-and-test@3.0.9(supports-color@5.5.0):
dependencies:
arg: 5.0.2
check-more-types: 2.24.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@5.5.0)
execa: 5.1.1
lazy-ass: 2.0.3
tree-kill: 1.2.2
wait-on: 9.0.10(debug@4.4.3(supports-color@10.2.2))
wait-on: 9.0.10(debug@4.4.3(supports-color@5.5.0))
transitivePeerDependencies:
- supports-color
@ -19638,18 +19551,6 @@ snapshots:
vue: 3.5.35(typescript@5.9.3)
vue-inbrowser-compiler-independent-utils: 4.71.1(vue@3.5.35(typescript@5.9.3))
vue-eslint-parser@10.4.1(eslint@9.39.4):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.4
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
espree: 11.2.0
esquery: 1.7.0
semver: 7.8.4
transitivePeerDependencies:
- supports-color
vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@ -19682,9 +19583,9 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
wait-on@9.0.10(debug@4.4.3(supports-color@10.2.2)):
wait-on@9.0.10(debug@4.4.3(supports-color@5.5.0)):
dependencies:
axios: 1.16.0(debug@4.4.3(supports-color@10.2.2))
axios: 1.16.0(debug@4.4.3(supports-color@5.5.0))
joi: 18.2.1
lodash: 4.18.1
minimist: 1.2.8

View file

@ -59,6 +59,8 @@ minimumReleaseAgeExclude:
- vite # 脆弱性対応。そのうち消す
- form-data # 脆弱性対応。そのうち消す
- tar
# Renovate security update: nodemailer@9.0.1
- nodemailer@9.0.1
overrides:
'@aiscript-dev/aiscript-languageserver': '-'
chokidar: 5.0.0