1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-01 16:24:59 +02:00

Compare commits

...

61 Commits

Author SHA1 Message Date
かっこかり
0137b1c406 fix: update compatible node version on master (#17632)
Update package.json
2026-06-27 23:39:29 +09:00
syuilo
99a4eeb87d Workflowの追従 (#17622)
* [skip ci] Update CHANGELOG.md (prepend template)

* fix(dev): tweak frontend-bundle-report

* Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

* enhance(dev): tweak Frontend Chunk Report

* fix(dev): tweak frontend-bundle-report-comment

* enhance(dev): tweak Frontend Chunk Report

* enhance(dev): tweak Frontend Chunk Report

* enhance(dev): improve backend memory usage comparison workflow (#17591)

* wip

* Update get-backend-memory.yml

* [ci skip] tweak table

* enhance(dev): tweak report-backend-memory

* refactor(dev): report-backend-memoryのmarkdown生成ロジックを分離

* enhance(dev): tweak report-backend-memory

* enhance(dev): tweak report-backend-memory

* enhance(dev): tweak report-backend-memory

* chore(dev): refactor

* refactor(dev): unify frontend-bundle-visualizer-report.mjs and frontend-js-size.mjs

* chore(dev): fix typo

* refactor(dev): refactor frontend-js-size.mjs

* chore(dev): tweak frontend-js-size.mjs

* chore(dev): tweak frontend-js-size.mjs

* chore(dev): tweak frontend-js-size.mjs

* chore(ci): simplify FFmpeg installation in workflows (#17612)

* chore(ci): simplify FFmpeg installation in workflows

* fix

* fix(dev): tweak frontend-js-size.mjs

* Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

* enhance(dev): improve Frontend Bundle Report

* chore(dev): refactor frontend bundle report

* enhance(dev): improve backend memory usage report (#17608)

* enhance(dev): tweak report-backend-memory

* wip

* Update backend-memory-report.mjs

* Update backend-memory-report.mjs

* chore(dev): tweak frontend-js-size.mjs

* Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* (test) enhance(dev): improve backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak frontend-js-size

* chore(dev): tweak frontend-js-size

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak backend-memory-report

* chore(dev): tweak frontend-js-size

* Update frontend-bundle-report.yml

* [ci skip] chore(dev): tweak frontend-js-size

* refactor(dev): refactor of backend memory comparison workflow (#17619)

* refactor(dev): refactor of backend memory comparison workflow

* fix

* fix(dev): follow up of 1c4bcd9b32

* fix(dev): follow up of 1c4bcd9b32

* fix(dev): follow up of 1c4bcd9b32

* fix(dev): follow up of 1c4bcd9b32

* chore(dev): refactor workflow js

* refactor(dev): extract heap snapshot logic

将来的にフロントエンドでもheap snaphotを集計したくなった時などのため
あとpairedDeltaSummaryを共通化

* chore(dev): tweak backend-memory-report

* chore(dev): tweak some workflows

* chore(dev): tweak some workflows

* fix(dev): fix measure-memory

* clean up

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-06-26 19:41:17 +09:00
misskey-release-bot[bot]
3c83952c48 Merge pull request #17546 from misskey-dev/develop
Release: 2026.6.0
2026-06-22 11:47:26 +00:00
github-actions[bot]
2954dee108 Release: 2026.6.0 2026-06-22 11:47:20 +00:00
かっこかり
00c6210a59 fix(backend): fix tests (#17606)
* fix(backend): fix tests

* attempt to fix test

* Revert "attempt to fix test"

This reverts commit ebe92c9dd9.

* fix

* fix

* test: fix test failure

---------

Co-authored-by: anatawa12 <anatawa12@icloud.com>
2026-06-22 20:18:40 +09:00
syuilo
2b87748537 Update CHANGELOG.md 2026-06-22 19:56:40 +09:00
syuilo
266a3c473b enhance(dev): improve frontend bundle report (#17600)
* wip

* Update package.json

* wip

* Update pnpm-lock.yaml

* wip

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* Update frontend-bundle-visualizer-report.mjs

* refactor

* Update frontend-js-size.yml

* refactor

* Update package.json
2026-06-22 19:41:03 +09:00
かっこかり
1eac4ccf51 Merge commit from fork 2026-06-22 16:34:48 +09:00
かっこかり
d323fe00d0 Merge commit from fork
* fix(backend): Prevent the reuse of used TOTP tokens

* fix

* fix

* tighten totp window
2026-06-22 16:34:15 +09:00
かっこかり
053e244582 fix(backend): summalyのバージョンを埋め込んでビルドするようにし、同一チャンクに巻き込まれないように (#17595)
* fix(backend): summalyのバージョンを埋め込んでビルドするようにし、同一チャンクに巻き込まれないように

* fix
2026-06-20 22:46:38 +09:00
かっこかり
c0a8c7f93a enhance(backend): SummalyのUser Agentを改善 (#17589)
* enhance(backend): SummalyのUser Agentを改善

* Update Changelog

* update summaly
2026-06-20 21:33:15 +09:00
syuilo
1d0b27b4c5 Update frontend-js-size.yml 2026-06-20 20:19:54 +09:00
syuilo
3c003d73c4 Update frontend-js-size.yml 2026-06-20 20:03:57 +09:00
syuilo
36d78a788d Update frontend-js-size.yml 2026-06-20 19:58:49 +09:00
syuilo
09f058f29a Update frontend-js-size.yml 2026-06-20 15:27:43 +09:00
syuilo
ad8b194643 Update frontend-js-size.yml 2026-06-20 15:10:59 +09:00
syuilo
4c9dd0e5ff Update frontend-js-size.yml 2026-06-20 14:55:54 +09:00
syuilo
0ced35ae6c Update frontend-js-size.yml 2026-06-20 14:33:47 +09:00
syuilo
dcced940af Update frontend-js-size.yml 2026-06-20 13:41:01 +09:00
syuilo
dc97a72fdb Update frontend-js-size.yml 2026-06-20 13:26:07 +09:00
syuilo
cc7f1e7366 enhance(dev/frontend-js-size): diffが大きい順にソートした上位10チャンクの表を追加 2026-06-20 11:49:15 +09:00
syuilo
77878256a8 fix(dev): follow up of 0956da49e9 2026-06-20 11:36:44 +09:00
syuilo
662129f414 fix(dev): follow up of 0956da49e9 2026-06-20 11:29:58 +09:00
syuilo
4457a75d22 fix(dev): follow up of 0956da49e9 2026-06-20 11:12:39 +09:00
syuilo
0956da49e9 feat(dev): フロントエンドのバンドルサイズ比較のAction (#17586)
Create frontend-js-size.yml
2026-06-20 11:00:58 +09:00
anatawa12
21a4f95bd6 fix: the script contains locale json is prefetched (#17585)
This commit upgrades rolldown used by vite to 1.1.0 and set
includeDependenciesRecursively instead of maxSize for
i18n code splitting group.

We unexpectedly prefetched the script file includes locale JSON
before this fix because locale inliner did not remove prefetch
for transitive dependency of i18n global variable.

Current locale inliner assumes the file contains i18n global
variable and the file contains i18n global variable are same file
and only removes prefetch for the file for i18n global variable
and leaves dependency files of the file.
However, in the previous fix for rolldown migration regression,
we set `maxSize: 1` for manual chunk of i18n.
This makes the chunk for i18n global variable (@/i18n.js) and
the chunk includes locale JSON (@@/js/locale.js) distinct chunks.
As a result, only prefetch for i18n global is removed and local
JSON remain in the prefetch file name dictionary (__vite__mapDeps).

There is two ways to fix this problem: 1) make rolldown to bundle
i18n related files into one but leave unrelated files separated
module or 2) update locale inliner to remove transitive dependency
of i18n of __vite__mapDeps.
2nd way is prune to rolldown changes, and it's possible by parsing
each .js file to (re)create module graph in inliner, it's complex.
Therefore, this commit fixes this with 1st way with
includeDependenciesRecursively option on `codeSplitting.groups`
newly added in rolldown 1.1.0.
Since latest vite as of writing (8.0.16) strictly depends on
rolldown 1.0.3, we cannot use it normally. We use overrides
to work around this problem. As far as I checked the vite
repository, upgrading rolldown to 1.1.x includes no code changes
except for package.json, so I hope this upgrade is safe.
2026-06-20 08:59:01 +09:00
misskey-release-bot[bot]
3ac6d287d6 Merge pull request #17458 from misskey-dev/develop
Release: 2026.5.4
2026-05-21 00:32:02 +00:00
misskey-release-bot[bot]
42a59b5d76 Merge pull request #17426 from misskey-dev/develop
Release: 2026.5.3
2026-05-18 01:44:55 +00:00
misskey-release-bot[bot]
138e66e618 Merge pull request #17397 from misskey-dev/develop
Release: 2026.5.2
2026-05-17 22:14:59 +00:00
misskey-release-bot[bot]
4188d68457 Merge pull request #17364 from misskey-dev/develop
Release: 2026.5.1
2026-05-06 10:44:22 +00:00
misskey-release-bot[bot]
6391a4e7e2 Merge pull request #17351 from misskey-dev/develop
Release: 2026.5.0
2026-05-02 03:30:56 +00:00
misskey-release-bot[bot]
41048638a2 Merge pull request #17232 from misskey-dev/develop
Release: 2026.3.2
2026-03-31 12:14:43 +00:00
misskey-release-bot[bot]
9c0e3e7937 Merge pull request #17230 from misskey-dev/develop
Release: 2026.3.1
2026-03-09 01:03:00 +00:00
misskey-release-bot[bot]
fe3dd8edb5 Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
2026-03-05 10:56:50 +00:00
misskey-release-bot[bot]
0d46089f9a Merge pull request #16998 from misskey-dev/develop
Release: 2025.12.2
2025-12-22 05:30:45 +00:00
misskey-release-bot[bot]
7420c10a58 Merge pull request #16972 from misskey-dev/develop
Release: 2025.12.1
2025-12-14 07:27:09 +00:00
misskey-release-bot[bot]
e40c84f31d Merge pull request #16916 from misskey-dev/develop
Release: 2025.12.0
2025-12-06 12:22:58 +00:00
misskey-release-bot[bot]
994fc062cf Merge pull request #16840 from misskey-dev/develop
Release: 2025.11.1
2025-11-28 10:04:09 +00:00
misskey-release-bot[bot]
e7681f6c79 Merge pull request #16759 from misskey-dev/develop
Release: 2025.11.0
2025-11-16 08:23:46 +00:00
misskey-release-bot[bot]
19053339d9 Merge pull request #16709 from misskey-dev/develop
Release: 2025.10.2
2025-10-27 04:19:45 +00:00
misskey-release-bot[bot]
b4e16c83e2 Merge pull request #16629 from misskey-dev/develop
Release: 2025.10.1
2025-10-24 06:31:35 +00:00
misskey-release-bot[bot]
56cc89b521 Merge pull request #16591 from misskey-dev/develop
Release: 2025.10.0
2025-10-08 13:18:08 +00:00
misskey-release-bot[bot]
1eab314b17 Merge pull request #16521 from misskey-dev/develop
Release: 2025.9.0
2025-09-08 12:29:29 +00:00
misskey-release-bot[bot]
ec21336d45 Merge pull request #16335 from misskey-dev/develop
Release: 2025.8.0
2025-08-31 08:42:43 +00:00
misskey-release-bot[bot]
e86e9b46b3 Merge pull request #16244 from misskey-dev/develop
Release: 2025.7.0
2025-07-18 00:28:01 +00:00
misskey-release-bot[bot]
9b729b3d25 Merge pull request #16197 from misskey-dev/develop
Release: 2025.6.3
2025-06-16 11:13:26 +00:00
misskey-release-bot[bot]
3c973e21f2 Merge pull request #16195 from misskey-dev/develop
Release: 2025.6.2
2025-06-16 08:58:35 +00:00
misskey-release-bot[bot]
830e2f0a5b Merge pull request #16152 from misskey-dev/develop
Release: 2025.6.1
2025-06-16 02:33:18 +00:00
misskey-release-bot[bot]
1620477a1c Merge pull request #16134 from misskey-dev/develop
Release: 2025.6.0
2025-06-02 00:58:34 +00:00
misskey-release-bot[bot]
92b9a5218d Merge pull request #16005 from misskey-dev/develop
Release: 2025.5.1
2025-05-31 12:37:06 +00:00
misskey-release-bot[bot]
9ed0d5ccec Merge pull request #15933 from misskey-dev/develop
Release: 2025.5.0
2025-05-07 02:46:42 +00:00
misskey-release-bot[bot]
a6d1727205 Merge pull request #15842 from misskey-dev/develop
Release: 2025.4.1
2025-04-30 09:01:47 +00:00
misskey-release-bot[bot]
3c3982464f Merge pull request #15735 from misskey-dev/develop
Release: 2025.4.0
2025-04-09 02:17:31 +00:00
misskey-release-bot[bot]
bef73ff530 Merge pull request #15615 from misskey-dev/develop
Release: 2025.3.1
2025-03-09 03:29:58 +00:00
misskey-release-bot[bot]
4d31c0b1de Merge pull request #15585 from misskey-dev/develop
Release: 2025.3.0
2025-03-06 10:31:34 +00:00
misskey-release-bot[bot]
a5f28c21e4 Merge pull request #15507 from misskey-dev/develop
Release: 2025.2.1
2025-02-27 08:58:43 +00:00
misskey-release-bot[bot]
c93ead7474 Merge pull request #15378 from misskey-dev/develop
Release: 2025.2.0
2025-02-05 08:58:45 +00:00
misskey-release-bot[bot]
36880493cb Merge pull request #15279 from misskey-dev/develop
Release: 2025.1.0
2025-01-28 12:29:14 +00:00
misskey-release-bot[bot]
e8518de054 Merge pull request #14924 from misskey-dev/develop
Release: 2024.11.0
2024-11-22 09:15:34 +00:00
misskey-release-bot[bot]
b99e13e667 Merge pull request #14741 from misskey-dev/develop
Release: 2024.10.1
2024-10-15 04:53:46 +00:00
misskey-release-bot[bot]
2518cf36d0 Merge pull request #14675 from misskey-dev/develop
Release: 2024.10.0
2024-10-09 05:17:29 +00:00
35 changed files with 3913 additions and 778 deletions

View File

@@ -0,0 +1,46 @@
/*
* 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

@@ -0,0 +1,46 @@
/*
* 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;
};

474
.github/scripts/backend-js-footprint.mjs vendored Normal file
View File

@@ -0,0 +1,474 @@
/*
* 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 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 = util.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: util.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

@@ -0,0 +1,425 @@
/*
* 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 * as heapSnapshotUtil from './heap-snapshot-util.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;
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 renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
function formatDeltaMemory(diffKiB: number) {
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
}
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 = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
const percent = summary.median * 100 / baseValue;
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`;
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 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 renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const baseHeapSnapshotReport = {
summary: base.summary.afterGc.heapSnapshot!,
samples: base.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const headHeapSnapshotReport = {
summary: head.summary.afterGc.heapSnapshot!,
samples: head.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
if (table == null) return null;
const lines = [
'### V8 Heap Snapshot Statistics',
'',
table,
'',
];
for (const graph of [
heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, '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)} | ${util.formatColoredDelta(formatter(headValue - baseValue), headValue - baseValue)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`);
}
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)} | ${util.formatColoredDelta(util.formatBytes(packageSummary.sourceBytes - packageSummary.baseSourceBytes), packageSummary.sourceBytes - packageSummary.baseSourceBytes)} | ${util.formatColoredDelta(util.formatNumber(packageSummary.modules - packageSummary.baseModules), packageSummary.modules - packageSummary.baseModules)} |`);
}
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 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 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`);

503
.github/scripts/frontend-js-size.mts vendored Normal file
View File

@@ -0,0 +1,503 @@
/*
* 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 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 treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
const body = [
marker,
'',
`## 📦 Frontend Bundle Report`,
'',
renderFrontendChunkReport(before, after),
'',
'## Bundle Stats',
'',
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
'',
visualizerArtifactLink,
].join('\n');
await fs.writeFile(outFile, body);

200
.github/scripts/heap-snapshot-util.mts vendored Normal file
View File

@@ -0,0 +1,200 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
import * as util from './utility.mts';
export const heapSnapshotCategory = {
total: { label: 'Total', color: 'gray', colorHex: '#888888' },
code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' },
strings: { label: 'Strings', color: 'red', colorHex: '#e15759' },
jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' },
typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' },
systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' },
otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' },
otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' },
} as const satisfies Record<string, { label: string; color: string; colorHex: string }>;
export type HeapSnapshotData = {
categories: Record<keyof typeof heapSnapshotCategory, number>;
nodeCounts: Record<keyof typeof heapSnapshotCategory, number>;
breakdowns?: Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
};
export type HeapSnapshotReport = {
summary: HeapSnapshotData;
samples: {
round: number;
data: HeapSnapshotData;
}[];
};
function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
return report.summary.categories[category];
}
export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
const baseTotal = getHeapSnapshotCategoryValue(base, 'total');
const headTotal = getHeapSnapshotCategoryValue(head, 'total');
function getHeapSnapshotSampleSpread(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
const values = report.samples
.map(sample => sample.data.categories[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length < 2) throw new Error(`Not enough samples for category ${category}`);
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const baseValue = getHeapSnapshotCategoryValue(base, category);
const headValue = getHeapSnapshotCategoryValue(head, category);
const baseSpread = getHeapSnapshotSampleSpread(base, category);
const headSpread = getHeapSnapshotSampleSpread(head, category);
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => sample.data.categories[category]);
const percent = summary.median * 100 / baseValue;
if (category === 'total') {
const deltaMedian = `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`;
const baseText = `${util.formatBytes(baseValue)} <br> ± ${util.formatBytes(baseSpread)}`;
const headText = `${util.formatBytes(headValue)} <br> ± ${util.formatBytes(headSpread)}`;
const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`);
lines.push('| | | | | | | |');
} else {
const deltaMedian = util.formatDeltaBytes(summary.median);
const baseText = util.formatBytes(baseValue);
const headText = util.formatBytes(headValue);
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
const headPercent = util.formatPercent((headValue * 100) / headTotal);
const metricText = `<details><summary>$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**</summary>${basePercent}${headPercent}</details>`;
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`);
}
}
if (lines.length === 2) return null;
return lines.join('\n');
}
const heapSnapshotSankeyChildMinRatio = 0.3;
const heapSnapshotSankeyParentMinPercent = 10;
function escapeCsvValue(value: string) {
return `"${String(value).replaceAll('"', '""')}"`;
}
export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) {
const total = getHeapSnapshotCategoryValue(report, 'total');
if (total == null || total <= 0) return null;
function getHeapSnapshotBreakdownEntries(category: keyof typeof heapSnapshotCategory) {
const breakdown = report.summary.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]);
}
function formatHeapSnapshotSankeyChildLabel(label: string) {
return String(label).replace(/^[^:]+:\s*/, '');
}
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(/\.$/, '');
}
const categories = (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[])
.filter(category => category !== 'total')
.map(category => {
const value = getHeapSnapshotCategoryValue(report, 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]: heapSnapshotCategory.total.colorHex,
} as Record<string, string>;
for (const { category, childEntries } of categories) {
const categoryColor = heapSnapshotCategory[category].colorHex;
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(heapSnapshotCategory[category].label)},${formatSankeyPercentValue(percent)}`);
for (const [childName, childPercent] of childEntries) {
lines.push(`${escapeCsvValue(heapSnapshotCategory[category].label)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
}
}
lines.push('```');
lines.push('');
lines.push('</details>');
return lines.join('\n');
}

View File

@@ -0,0 +1,259 @@
/*
* 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 * as heapSnapshotUtil from './heap-snapshot-util.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?: heapSnapshotUtil.HeapSnapshotData;
}>;
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<keyof typeof heapSnapshotUtil.heapSnapshotCategory, Record<string, number>>;
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
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)) as number[];
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<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
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<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
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);
});

204
.github/scripts/utility.mts vendored Normal file
View File

@@ -0,0 +1,204 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
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) throw new Error('Not enough samples to calculate MAD');
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
function getSamplesByRound<T extends { round: number; }[]>(samples: T) {
const samplesByRound = new Map<number, T[number]>();
for (const sample of samples) {
if (sample.round <= 0) continue;
samplesByRound.set(sample.round, sample);
}
return samplesByRound;
}
export function pairedDeltaSummary<T extends { round: number; }[]>(baseSamples: T, headSamples: T, getValue: (sample: T[number]) => number | null) {
const baseSamplesByRound = getSamplesByRound(baseSamples);
const headSamplesByRound = getSamplesByRound(headSamples);
const values = [];
for (const [round, baseSample] of baseSamplesByRound) {
const headSample = headSamplesByRound.get(round);
if (headSample == null) continue;
const baseValue = getValue(baseSample);
const headValue = getValue(headSample);
if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue);
}
return {
median: median(values),
mad: mad(values),
min: Math.min(...values),
max: Math.max(...values),
samples: values.length,
};
}
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', 'KB', 'MB', 'GB'];
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

@@ -0,0 +1,318 @@
name: frontend-bundle-report-comment
on:
workflow_run:
workflows:
- frontend-bundle-report
types:
- completed
pull_request_target:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/scripts/utility.mts
- .github/scripts/frontend-js-size.mts
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend bundle report
if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
concurrency:
group: frontend-bundle-report-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Find bundle report run
if: github.event_name == 'pull_request_target'
id: find-report-run
uses: actions/github-script@v9
with:
script: |
const workflow_id = 'frontend-bundle-report.yml';
const artifactName = 'frontend-bundle-report';
const headSha = context.payload.pull_request.head.sha;
const prNumber = context.payload.pull_request.number;
const pollIntervalMs = 30_000;
const timeoutMs = 90 * 60_000;
const startedAt = Date.now();
const { owner, repo } = context.repo;
async function listReportWorkflowRuns() {
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id,
event: 'pull_request',
head_sha: headSha,
per_page: 100,
});
if (runsForHead.length > 0) {
return runsForHead;
}
const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id,
event: 'pull_request',
per_page: 100,
});
return recentRuns.filter((run) =>
run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber));
}
async function findReportRun() {
const runs = (await listReportWorkflowRuns())
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
for (const run of runs) {
if (run.status !== 'completed') continue;
if (run.conclusion !== 'success') {
core.warning(`Frontend bundle report run ${run.id} completed with conclusion: ${run.conclusion}`);
return { done: true, run: null };
}
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: run.id,
per_page: 100,
});
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 };
}
while (Date.now() - startedAt < timeoutMs) {
const { done, run } = await findReportRun();
if (run) {
core.info(`Found frontend bundle report on workflow run ${run.id}.`);
core.setOutput('run-id', String(run.id));
return;
}
if (done) {
return;
}
core.info('Waiting for frontend bundle report artifact...');
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
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'
uses: actions/download-artifact@v8
with:
name: frontend-bundle-report
path: ${{ runner.temp }}/frontend-bundle-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Download bundle report from pull_request_target
if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != ''
uses: actions/download-artifact@v8
with:
name: frontend-bundle-report
path: ${{ runner.temp }}/frontend-bundle-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
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 != ''
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 }}
script: |
const fs = require('node:fs');
const path = require('node:path');
const jsSizeMarker = '<!-- misskey-frontend-js-size -->';
const visualizerMarker = '<!-- misskey-frontend-bundle-visualizer -->';
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 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 { owner, repo } = context.repo;
if (!fs.existsSync(jsSizeReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-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.`);
}
const reportHeadSha = artifactHeadSha ?? eventHeadSha;
const artifactPrNumber = fs.existsSync(prNumberPath)
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
: null;
let issue_number = null;
if (pullRequest != null) {
issue_number = pullRequest.number;
if (Number.isInteger(artifactPrNumber) && artifactPrNumber !== issue_number) {
core.setFailed(`The artifact pull request number (${artifactPrNumber}) does not match the event pull request number (${issue_number}).`);
return;
}
} else if (workflowRun != null) {
const associatedPullRequests = new Map();
for (const pullRequest of workflowRun.pull_requests ?? []) {
if (Number.isInteger(pullRequest.number)) {
associatedPullRequests.set(pullRequest.number, pullRequest);
}
}
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);
}
}
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}.`);
return;
} else {
core.setFailed(`Could not determine the pull request associated with ${reportHeadSha}.`);
return;
}
} else {
core.setFailed('Could not determine the pull request event for this report.');
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 maxCommentLength = 65_000;
if (body.length > maxCommentLength) {
const reportLocation = workflowRun?.html_url != null
? `[workflow run](${workflowRun.html_url})`
: 'workflow artifact';
const footer = [
'',
'',
`_Report truncated because it exceeded ${maxCommentLength.toLocaleString('en-US')} characters. See the ${reportLocation} for the full report._`,
].join('\n');
body = `${body.slice(0, maxCommentLength - footer.length)}${footer}`;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const previousReports = comments.filter((comment) =>
comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker)));
if (previousReports.length > 0) {
const [previous, ...duplicates] = previousReports;
await github.rest.issues.updateComment({
owner,
repo,
comment_id: previous.id,
body,
});
for (const duplicate of duplicates) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: duplicate.id,
});
}
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}

View File

@@ -0,0 +1,170 @@
name: frontend-bundle-report
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/scripts/utility.mts
- .github/scripts/frontend-js-size.mts
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-bundle-report-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Build frontend bundle report
runs-on: ubuntu-latest
env:
FRONTEND_JS_SIZE_LOCALE: ja-JP
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Check base visualizer support
id: check-base-visualizer
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"
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
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
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_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_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"
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"
cat "$REPORT_DIR/frontend-js-size-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
path: ${{ runner.temp }}/frontend-bundle-report/
if-no-files-found: error
retention-days: 7

View File

@@ -9,7 +9,14 @@ 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:
@@ -17,15 +24,6 @@ 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
@@ -40,37 +38,77 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
ref: ${{ matrix.ref }}
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
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: '.node-version'
node-version-file: 'head/.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
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: git diff --exit-code pnpm-lock.yaml
- 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: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
- name: Configure base
working-directory: base
run: |
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
cp .github/misskey/test.yml .config/default.yml
pnpm compile-config
- name: Build base
working-directory: base
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
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
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
name: memory-artifact-results
path: |
memory-base.json
memory-head.json
js-footprint-base.json
js-footprint-head.json
save-pr-number:
runs-on: ubuntu-latest

View File

@@ -11,9 +11,14 @@ 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:
@@ -48,120 +53,13 @@ jobs:
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-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"
- name: Output base JS footprint
run: cat ./artifacts/js-footprint-base.json
- name: Output head JS footprint
run: cat ./artifacts/js-footprint-head.json
- id: build-comment
name: Build memory comment
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
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
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}

View File

@@ -19,12 +19,6 @@ 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:
@@ -62,36 +56,9 @@ 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: |
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
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -15,12 +15,6 @@ 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:
@@ -37,36 +31,9 @@ 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: |
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
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -1 +1 @@
22.15.0
22.18.0

View File

@@ -25,6 +25,7 @@
- Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善
- Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善
- Enhance: ActivityPub の画像添付に width/height を含めるように
- Enhance: URLプレビューのデフォルトの User Agent に Misskey サーバーのURLを含めるように
- Fix: backend バンドルで `@tensorflow/tfjs-node` を external に含めず、起動時に `@mapbox/node-pre-gyp``find()` が backend の package.json を誤検出して `is not node-pre-gyp ready` エラーを永続的に吐く問題を修正
- Fix: MemoryKVCacheのキャッシュGC処理において、更新されたキャッシュが期限切れにならないことがある問題を修正
- Fix: PerUserDriveChart がシステム所有ファイル (userId が null) の更新で `"group"` の非NULL制約違反によりクラッシュする問題を修正 (#17498)
@@ -33,6 +34,7 @@
- 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-beta.1",
"version": "2026.6.0",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": "^22.15.0 || ^24.10.0"
"node": "^22.15.0 || ^24.10.0 || ^26.4.0"
},
"scripts": {
"start": "pnpm compile-config && node ./built/entry.js",
@@ -64,7 +64,7 @@
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.3.0",
"@misskey-dev/summaly": "5.5.1",
"@napi-rs/canvas": "1.0.0",
"@nestjs/common": "11.1.26",
"@nestjs/core": "11.1.26",

View File

@@ -1,4 +1,5 @@
import { defineConfig } from 'rolldown';
import { version as summalyVersion } from '@misskey-dev/summaly';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
@@ -84,6 +85,11 @@ export default defineConfig((args) => {
'file-type',
];
const define: Record<string, string> = {
// Summalyのバージョンを埋め込む
'_SUMMALY_VERSION_': JSON.stringify(summalyVersion),
};
if (isE2E) {
return {
input: './test-server/entry.ts',
@@ -92,6 +98,9 @@ export default defineConfig((args) => {
plugins: [
esmShim(),
],
transform: {
define,
},
output: {
keepNames: true,
sourcemap: true,
@@ -116,6 +125,9 @@ export default defineConfig((args) => {
esmShim(),
(isWatchMode ? backendDevServerPlugin() : undefined),
],
transform: {
define,
},
output: {
keepNames: true,
minify: !isWatchMode,

View File

@@ -1,231 +0,0 @@
/*
* 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

@@ -0,0 +1,535 @@
/*
* 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';
import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
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 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 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): keyof typeof heapSnapshotCategory {
if (type === 'code') return 'code';
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'strings';
if (isTypedArrayNode(type, name)) return 'typedArrays';
if (type === 'array' || (type === 'object' && name === 'Array')) return 'jsArrays';
if (isSystemNode(type, name)) return 'systemObjects';
if (otherJsNodeTypes.has(type)) return 'otherJsObjects';
return 'otherNonJsObjects';
}
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: keyof typeof heapSnapshotCategory, type, name) {
if (category === 'strings') return type;
if (category === 'jsArrays') {
if (type === 'array') return 'array nodes';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'typedArrays') {
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 === 'systemObjects') {
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'otherJsObjects') {
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
return type;
}
if (category === 'otherNonJsObjects') {
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: Record<string, Record<string, number>>) {
const collapsed = {} as Record<string, Record<string, number>>;
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');
function createEmptyHeapSnapshotCategoryMap() {
return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record<keyof typeof heapSnapshotCategory, number>;
}
const fieldCount = nodeFields.length;
const categories = createEmptyHeapSnapshotCategoryMap();
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
const breakdowns = Object.fromEntries(
(Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[])
.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): Promise<HeapSnapshotData | null> {
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

@@ -0,0 +1,6 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare const _SUMMALY_VERSION_: string;

View File

@@ -6,6 +6,7 @@
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;
@@ -31,7 +32,7 @@ export async function server() {
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test') {
if (process.env.NODE_ENV !== 'test' && !envOption.noDaemons) {
const { ChartManagementService } = await import('../core/chart/ChartManagementService.js');
const { QueueStatsService } = await import('../daemons/QueueStatsService.js');
const { ServerStatsService } = await import('../daemons/ServerStatsService.js');
@@ -54,7 +55,9 @@ export async function jobQueue() {
});
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
if (!envOption.noDaemons) {
jobQueue.get(ChartManagementService).start();
}
return jobQueue;
}

View File

@@ -9,6 +9,7 @@
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';
@@ -91,10 +92,35 @@ process.on('message', msg => {
if (msg === 'gc') {
if (global.gc != null) {
logger.info('Manual GC triggered');
global.gc();
for (let i = 0; i < 3; i++) {
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

@@ -4,17 +4,19 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
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';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import type { MiLocalUser } from '@/models/User.js';
@Injectable()
export class UserAuthService {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -30,16 +32,58 @@ export class UserAuthService {
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
});
} else {
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
digits: 6,
token,
window: 5,
});
if (delta === null) {
if (!await this.validateOtp(profile.userId, profile.twoFactorSecret!, token)) {
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,6 +10,7 @@ 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,
@@ -45,6 +46,7 @@ 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) => {
@@ -56,14 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('二段階認証の設定が開始されていません');
}
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
digits: 6,
token,
window: 5,
});
if (delta === null) {
if (!await this.userAuthService.validateOtp(profile.userId, profile.twoFactorTempSecret, token)) {
throw new Error('not verified');
}

View File

@@ -19,6 +19,7 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
export class UrlPreviewService {
private logger: Logger;
private readonly summalyDefaultUserAgent: string;
constructor(
@Inject(DI.config)
@@ -31,6 +32,7 @@ export class UrlPreviewService {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('url-preview');
this.summalyDefaultUserAgent = `SummalyBot/${_SUMMALY_VERSION_} (${this.config.url}; +https://github.com/misskey-dev/summaly/blob/master/README.md)`;
}
@bindThis
@@ -113,20 +115,16 @@ export class UrlPreviewService {
}
private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const agent = this.config.proxy
? {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
}
: undefined;
const { summaly } = await import('@misskey-dev/summaly');
return summaly(url, {
followRedirects: this.meta.urlPreviewAllowRedirect,
lang: lang ?? 'ja-JP',
agent: agent,
userAgent: meta.urlPreviewUserAgent ?? undefined,
agent: {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
},
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: meta.urlPreviewTimeout,
contentLengthLimit: meta.urlPreviewMaximumContentLength,
contentLengthRequired: meta.urlPreviewRequireContentLength,
@@ -139,7 +137,7 @@ export class UrlPreviewService {
url: url,
lang: lang ?? 'ja-JP',
followRedirects: this.meta.urlPreviewAllowRedirect,
userAgent: meta.urlPreviewUserAgent ?? undefined,
userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent,
operationTimeout: meta.urlPreviewTimeout,
contentLengthLimit: meta.urlPreviewMaximumContentLength,
contentLengthRequired: meta.urlPreviewRequireContentLength,

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 } from '../utils.js';
import { api, signup, sendEnvUpdateRequest } 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, test } from 'vitest';
import { describe, beforeAll, beforeEach, test } from 'vitest';
describe('2要素認証', () => {
let alice: misskey.entities.SignupResponse;
@@ -181,6 +181,10 @@ 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,
@@ -487,4 +491,33 @@ 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

@@ -29,7 +29,7 @@
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.3.0",
"@misskey-dev/summaly": "5.5.1",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.9",

View File

@@ -153,11 +153,10 @@ export function getConfig(): UserConfig {
name: 'vue',
test: /node_modules[\\/]vue/,
}, {
// split each i18n related module to each distinct module, deny hoisting
// split i18n related module to distinct module
name: 'i18n',
test: /i18n\.ts/,
minSize: 0,
maxSize: 1,
includeDependenciesRecursively: false,
test: /i18n\.ts|locale\.ts/,
}],
},
entryFileNames: `scripts/${localesHash}-[hash:8].js`,

View File

@@ -72,7 +72,7 @@
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.3.0",
"@misskey-dev/summaly": "5.5.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@storybook/addon-essentials": "8.6.18",
@@ -129,6 +129,7 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"rolldown": "1.1.0",
"rollup-plugin-visualizer": "7.0.1",
"sass-embedded": "1.100.0",
"seedrandom": "3.0.5",
"start-server-and-test": "3.0.9",

View File

@@ -2,7 +2,8 @@ import path from 'path';
import pluginVue from '@vitejs/plugin-vue';
import pluginGlsl from 'vite-plugin-glsl';
import { replacePlugin } from 'rolldown/plugins';
import type { UserConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
import type { PluginOption, UserConfig } from 'vite';
import { defineConfig } from 'vite';
import * as yaml from 'js-yaml';
import { promises as fsp } from 'fs';
@@ -23,6 +24,34 @@ const host = url ? (new URL(url)).hostname : undefined;
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
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 = [
visualizer({
...visualizerOptions,
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE,
template: 'raw-data',
}) 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;
}
/**
* 検索インデックスの生成設定
*/
@@ -129,6 +158,7 @@ export function getConfig(): UserConfig {
}),
]
: [],
...getBundleVisualizerPlugin(),
],
resolve: {
@@ -194,11 +224,10 @@ export function getConfig(): UserConfig {
name: 'photoswipe',
test: /node_modules[\\/]photoswipe/,
}, {
// split each i18n related module to each distinct module, deny hoisting
// split i18n related module to distinct module
name: 'i18n',
test: /i18n\.ts/,
minSize: 0,
maxSize: 1,
includeDependenciesRecursively: false,
test: /i18n\.ts|locale\.ts/,
}],
},
entryFileNames: `scripts/${localesHash}-[hash:8].js`,

View File

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

746
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,8 @@ overrides:
'@aiscript-dev/aiscript-languageserver': '-'
chokidar: 5.0.0
lodash: 4.18.1
# remove when vite is updated to versions with rolldown > 1.1.0
vite>rolldown: "~1.1.0"
engineStrict: true
saveExact: true
shellEmulator: true