1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-17 20:54:43 +02:00

Compare commits

..

175 Commits

Author SHA1 Message Date
github-actions[bot]
5a6b9439b6 Bump version to 2026.7.0-alpha.6 2026-07-17 03:49:06 +00:00
おさむのひと
3de6b610b5 enhance: JSON形式の構造化ログ出力に対応 (#17728)
* enhance: JSON形式の構造化ログ出力に対応

* 起動時ログがJSON化出来ていなかったのを調整

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-17 12:45:26 +09:00
syuilo
3760e7a9f8 chore(gh): fix workflow name 2026-07-17 11:05:21 +09:00
syuilo
88c6cd1766 refactor(gh): unify heap snapshot output paths 2026-07-16 23:19:25 +09:00
syuilo
0272ee73eb refacotr(gh): refactor 2026-07-16 22:40:45 +09:00
syuilo
ab21fc725b refacotr(gh): refactor 2026-07-16 22:27:32 +09:00
syuilo
5d0ecf976d refacotr(gh): refactor 2026-07-16 22:12:15 +09:00
syuilo
ceb31f16c8 refacotr(gh): refactor 2026-07-16 21:58:01 +09:00
syuilo
b028d4ad5b refacotr(gh): rename some files 2026-07-16 21:53:36 +09:00
syuilo
68193dd38c refacotr(gh): refactor 2026-07-16 21:46:59 +09:00
syuilo
203a6b3792 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-16 21:44:56 +09:00
syuilo
2874a6d5ba refacotr(gh): refactor 2026-07-16 21:44:55 +09:00
かっこかり
8aff44ae2e refactor(misskey-js): モデレーションログの型を手動で書かずにすむように (#17729)
* refactor(misskey-js): モデレーションログの型を手動で書かずにすむように

* fix lint
2026-07-16 21:37:07 +09:00
syuilo
153875e1d9 [skip ci] refacotr(gh): add note 2026-07-16 21:28:05 +09:00
syuilo
fd83c9cb64 enhance(gh): tweak frontend-browser-diagnostics 2026-07-16 21:25:32 +09:00
syuilo
64f98faca9 refacotr(gh): refactor 2026-07-16 21:11:41 +09:00
syuilo
ab92f98472 refacotr(gh): rename some files 2026-07-16 21:02:45 +09:00
syuilo
77c2f54fa5 refacotr(gh): refactor 2026-07-16 20:42:58 +09:00
syuilo
1a04d4c2d9 refacotr(gh): rename some files 2026-07-16 20:36:55 +09:00
syuilo
12be1fe5b5 [skip ci] chore(dev): tweak backend-memory-report 2026-07-16 20:23:01 +09:00
syuilo
93bbc89c7a chore(dev): tweak frontend browser diagnostics 2026-07-16 19:58:24 +09:00
syuilo
875554003e feat(dev): report frontend browser diagnostics 2026-07-16 19:34:48 +09:00
おさむのひと
828a24f6df enhance: プロセス起動直後のログから新logger適用+logger設定機構の追加 (#17725)
* enhance: プロセスのライフサイクルに合わせてlogger初期化・終了+logger設定機構の追加

* fix

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-16 17:39:28 +09:00
syuilo
ac8e8c4892 chore(dev): tweak get-backend-memory 2026-07-16 16:35:36 +09:00
syuilo
b5e470b6d4 enhance(dev): tweak get-backend-memory 2026-07-16 15:59:41 +09:00
syuilo
212fcaf254 enhance(dev): tweak get-backend-memory 2026-07-16 14:44:49 +09:00
syuilo
22cdc24b04 add test 2026-07-16 14:10:30 +09:00
syuilo
df063d9386 enhance(dev): tweak get-backend-memory 2026-07-16 12:20:34 +09:00
syuilo
3c1f01af10 feat(dev): frontend browser metrics report (#17630)
* wip

* Update measure-frontend-browser-comparison.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* refactor

* fix

* Update chrome.mts

* refactor

* wip

* fix

* Update frontend-browser-metrics-report.yml

* Update frontend-browser-report.mts

* refactor

* wip

* Update frontend-browser-report.mts

* wip

* wip

* wip

* Revert "wip"

This reverts commit ce7657081b.

* Update frontend-browser-metrics-report.yml

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* Update frontend-browser-report.mts

* playwright

* Update chrome.mts

* refactor

* wip

* Update measure-frontend-browser-comparison.mts

* clean up

* Update measure-frontend-browser-comparison.mts

* fix

* Update chrome.mts

* refactor

* Update frontend-browser-metrics-report.yml
2026-07-16 10:21:30 +09:00
syuilo
0f3210946d fix(dev): refine frontend chunk report layout 2026-07-16 09:35:18 +09:00
syuilo
61b8332988 Merge branch 'codex/frontend-bundle-small-deltas' into develop 2026-07-16 09:07:09 +09:00
syuilo
8439080916 Merge remote-tracking branch 'origin/develop' into develop 2026-07-16 09:07:00 +09:00
syuilo
112263135e fix(dev): group small frontend chunk deltas 2026-07-16 09:04:54 +09:00
syuilo
2199f9040d docs: plan small frontend chunk aggregation 2026-07-16 09:01:17 +09:00
おさむのひと
1e4260cfb1 feat: add Japanese language support and tone instructions (#17720) 2026-07-16 08:59:31 +09:00
syuilo
a7fd58e4a5 docs: define small frontend chunk aggregation 2026-07-16 08:57:56 +09:00
syuilo
fb483d8367 Merge branch 'codex/frontend-bundle-generated-chunks' into develop 2026-07-15 22:06:40 +09:00
syuilo
ef852e6ab0 fix(dev): validate frontend startup manifest 2026-07-15 21:55:12 +09:00
syuilo
0ad72528da test(dev): check frontend bundle report 2026-07-15 21:34:13 +09:00
syuilo
11c6fb1567 test(dev): capture expected frontend report failures 2026-07-15 21:29:01 +09:00
syuilo
afca066833 fix(dev): clarify frontend chunk comparisons 2026-07-15 21:22:28 +09:00
syuilo
945fd40578 fix(dev): aggregate generated frontend chunks 2026-07-15 21:15:17 +09:00
syuilo
b6996a2766 chore: ignore local worktrees 2026-07-15 21:00:39 +09:00
syuilo
9076a4ecf3 docs: plan generated chunk reporting fix 2026-07-15 20:56:56 +09:00
syuilo
d74a7a515c docs: design generated chunk handling 2026-07-15 20:46:42 +09:00
syuilo
ce411817b4 Update .coderabbit.yaml 2026-07-15 20:44:18 +09:00
おさむのひと
0849dc7cdd enhance: 構造化ログAPIと正規化 (#17719)
* enhance: 構造化ログAPIと正規化

* fix changelog

* fixed copilot review

* fixed coderabbit review
2026-07-15 20:14:19 +09:00
syuilo
ed70123ace Create .coderabbit.yaml 2026-07-15 19:12:33 +09:00
github-actions[bot]
1110d334c8 Bump version to 2026.7.0-alpha.5 2026-07-15 04:36:32 +00:00
おさむのひと
667919581e enhance: 新しいLoggerのコア実装 (#17714)
* enhance: impl logger core

* fix order
2026-07-15 13:29:39 +09:00
かっこかり
152e0fc48c perf(frontend): タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように (#17708)
* perf(frontend): タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように

* fix

* wip

* fix

* fix

* Update Changelog

* spdx

* remove unnecessary css declaration
2026-07-15 12:06:25 +09:00
かっこかり
e3cb641c98 Update CHANGELOG.md 2026-07-15 00:03:14 +09:00
おさむのひと
4d7e62d677 feat: Otel pgとredis自動計装 (#17712) 2026-07-14 20:36:02 +09:00
おさむのひと
ecd85e5613 feat: OTelのHTTPサーバ自動計装を追加 (#17711) 2026-07-14 18:06:25 +09:00
github-actions[bot]
91ea966b04 Bump version to 2026.7.0-alpha.4 2026-07-14 07:58:57 +00:00
おさむのひと
3d4c62b0bb feat: BullMQのジョブ追加時に発動する手動計測の追加 (#17710)
* feat: BullMQのジョブ追加時に発動する手動計測の追加

* add comment
2026-07-14 16:36:40 +09:00
おさむのひと
a98469d34d feat: OTelのHTTPリクエスト自動計装を追加 (#17709) 2026-07-14 14:13:33 +09:00
renovate[bot]
5a2acf71a0 fix(deps): update dependency @opentelemetry/core to v2.8.0 [security] (#17707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 11:39:49 +09:00
おさむのひと
49e59a3d56 feat(backend): add OTLP trace export (#17679)
* feat(backend): add OTLP trace export

* fix

* regenerate

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix

* fix

* fix

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 11:22:14 +09:00
おさむのひと
ecd1d615ff fix(frontend): 未ログイン時のトップページがスクロール不可だったのを修正 (#17681)
* fix(frontend): 未ログイン時のトップページがスクロール不可だったのを修正

* fix CHANGELOG.md

* remove playwright file

* fix(frontend): 非ログイン時トップページを背景の上でもスクロールできるよう修正

.root を min-height から develop と同じ height + overflow: auto に戻して .root 自身を
スクロールコンテナとし、装飾レイヤ (.bg / .shape1 / .shape2) の pointer-events: none と
併せて、コンテンツ列以外 (背景) の上でもホイールスクロールが効くようにする。
CSS のみの対応で、TL のマーキーアニメーションやノート / フェデレーションのクリック挙動は
変更しない。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:21:18 +09:00
Genki.Takeuchi
53c398ce43 fix(frontend): loadRawImages有効時、リモート画像をタップしてからビューワーが開くまでに初回のみラグが発生する問題を修正 (#17703)
ビューワーを表示するトリガーをサムネイル画像又はオリジナル画像がロードされたときに修正
2026-07-13 19:58:05 +09:00
かっこかり
f74b427e67 fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制 (#17701)
* fix(backend): configテンプレートのallowedPrivateNetworksをリスト記法に変更してエラーの発生を抑制

* fix
2026-07-12 17:15:13 +09:00
かっこかり
aab7903ab0 fix(backend): summalyの結果のキャッシュが後続処理によって書き換わる問題を修正
Follow-up of #17367
2026-07-12 16:54:57 +09:00
github-actions[bot]
6ceda0f004 Bump version to 2026.7.0-alpha.3 2026-07-12 03:12:51 +00:00
かっこかり
df5c491f4c enhance(backend): summalyの結果をキャッシュするように (#17367)
* enhance(backend): summalyの結果をキャッシュするように

* fix

* fix: unify memorykvcache

* fix

* refactor

* fix

* Update Changelog
2026-07-12 12:08:59 +09:00
syuilo
15c187ffa2 enhance(frontend): misskey-native lightbox (#17687)
* wip

* wip

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* wip

* wip

* wip

* wip

* wip

* Update MkImageGallery.vue

* Update MkImageGallery.vue

* wip

* wip

* Update MkMediaList.vue

* Update MkImageGallery.item.vue

* fix

* wip

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.item.vue

* refactor

* Update MkImageGallery.item.vue

* Update MkImageGallery.vue

* Delete animation.ts

* wip

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.vue

* Update MkImageGallery.vue

* Update MkImageGallery.item.vue

* fix

* Update MkImageGallery.item.vue

* add note

* lint

* Update MkMediaList.vue

* refactor

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* fix

* Update MkImageGallery.item.vue

* wip

* Update MkImageGallery.item.vue

* wip

* wip

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkImageGallery.item.vue

* Update MkVideoContol.vue

* wip

* Update MkMediaVideo.vue

* Update MkMediaVideo.vue

* wip

* Update MkImageGallery.vue

* Update MkVideoContol.vue

* Update MkVideoContol.vue

* Improve image viewer (#17696)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix(frontend/imagegallery): ボタンの当たり判定がまだ小さいのを修正 (#17697)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo

* fix

* Update Changelog

* indent

* fix

* fix

* refactor: rename componet

* fix

* fix

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-12 11:58:44 +09:00
おさむのひと
b4abbe432e fix: ensure color output for child processes in Windows environment (#17695) 2026-07-11 15:27:34 +09:00
おさむのひと
2335eb7b7f chore: pnpm dev時のプロセス管理をより綿密に行い、Windows環境でのプロセス残留を抑止 (#17690) 2026-07-10 21:51:01 +09:00
かっこかり
059fbaa92b fix(frontend/test): follow-up of #17682 of 2 (#17685)
* fix(frontend/test): follow-up of #17682 of 2

* fix
2026-07-08 22:37:19 +09:00
かっこかり
76ab562810 fix: follow-up of #17682 [ci skip] 2026-07-08 21:19:35 +09:00
かっこかり
9472e72678 enhance(frontend/test): frontend e2eをPlaywrightに移行 (#17682)
* enhance(frontend/test): frontend e2eをVitest Browser Modeに移行

* fix

* fix unit tests

* fix

* fix

* update playwright

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* perf

* Revert "perf"

This reverts commit e60e965c3c.

* fix
2026-07-08 20:58:02 +09:00
かっこかり
9440423a73 refactor(frontend): フロントエンドのfluent-emojisをemoji-assetsから取ってくるように (#17662)
refactor(frontend): フロントエンドのfluent-emojis使用箇所を `@misskey-dev/emoji-assets` のものに変更
2026-07-08 20:11:13 +09:00
かっこかり
b2867eb0d7 fix(frontend): ディレクティブのイベントリスナーがマウント解除後も残る問題を修正 (#17683)
* fix(frontend): ツールチップがマウント解除後もイベントリスナーが残る問題を修正

* more fix

* fix

* fix/refactor

* Update Changelog

* update comment

* refactor

* fix
2026-07-08 19:45:43 +09:00
syuilo
5590f8bad9 chore(backend): COOP/COEPヘッダーを送信するテスト用引数 2026-07-08 13:15:03 +09:00
おさむのひと
c15f93e48d fix: Sentry 使用環境下にて、外部送信リクエストへ sentry-trace / baggage ヘッダが既定で付与されないように (#17677)
* fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように

* fix
2026-07-08 09:46:44 +09:00
github-actions[bot]
03d224acd0 Bump version to 2026.7.0-alpha.2 2026-07-06 10:03:45 +00:00
かっこかり
c24ce9b55a deps: update dependencies (#17669)
* deps: update dependencies

* deps: update dependencies (major)

* migrate js-yaml

* migrate js-yaml

* run pnpm dedupe

* fix

* chore: reorganize dependencies

* perf: lazy-load id generators

* Revert "perf: lazy-load id generators"

This reverts commit 041f7c4206.
2026-07-06 18:55:06 +09:00
おさむのひと
7243bd34b9 fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 (#17671)
* fix: pgをexternalにしてSentryのspanが動くようにする

* fix changelog

* add comment
2026-07-06 18:54:04 +09:00
おさむのひと
d4dd02c744 feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する (#17673)
* feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する

* fix comment
2026-07-06 18:52:57 +09:00
syuilo
3817f00803 Revert "fix(dev): fix typo of report-api-diff.yml"
This reverts commit d1cac26cfb.
2026-07-05 14:11:05 +09:00
github-actions[bot]
f066c1c2ef Bump version to 2026.7.0-alpha.1 2026-07-05 05:06:33 +00:00
かっこかり
1d41cb3d29 perf(frontend): UNIX時計ウィジェットでrequestAnimationFrameを使用するように (#17665) 2026-07-05 13:08:44 +09:00
かっこかり
1858d56918 fix(frontend): ワードミュートの判定が誤っている問題を修正 (#17668) 2026-07-05 13:07:17 +09:00
かっこかり
97082efd99 Update CHANGELOG.md [ci skip] 2026-07-04 20:46:03 +09:00
Johann150
187610d516 fix(frontend): correctly detect isMe in mention (#17664) 2026-07-04 20:43:42 +09:00
かっこかり
00b2cb2076 fix(backend/test): streaming.ts e2e の検証不備を修正 (#17663)
fix(backend/test): streaming.ts e2e の検証不備を修正 (tiramiss-community/endolphin#113)

Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com>
2026-07-04 18:53:10 +09:00
おさむのひと
426f6748e5 fix: frontend-builderのutils.tsが型エラーを起こしていたのを修正 (#17649) 2026-07-04 17:44:42 +09:00
かっこかり
c423bf92a8 Update CHANGELOG.md 2026-07-04 16:47:31 +09:00
かっこかり
f59bf35615 feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (#17635)
* URLプレビューのサムネイルを隠す機能を追加 (MisskeyIO#214)

* Update Changelog

* fix

* Update Changelog

* fix

* fix: もうセンシティブと判定されている場合はそれをそのまま通すように

* fix: unnecessary import

* fix lint

---------

Co-authored-by: CyberRex <hspwinx86@gmail.com>
2026-07-04 16:26:22 +09:00
おさむのひと
0f056c4955 refactor: Sentry呼び出し個所の抽象化 (#17661) 2026-07-04 16:06:24 +09:00
かっこかり
eb2c7ff6c6 fix(backend/test): follow-up of #17654 (waitForを他のe2eテストにも展開) (#17659)
test(backend): block.ts等のfanout timeline反映待ちレースを修正する (tiramiss-community/endolphin#112)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-04 11:21:23 +09:00
おさむのひと
9f614517c0 fix: tsconfig.jsonにskipLibCheck: trueを追加する (#17651)
* fix: tsconfig.jsonにskipLibCheck: trueを追加する

* skipLibCheckで抜けたチェックを自前でやる
2026-07-03 19:22:23 +09:00
github-actions[bot]
5432984af8 Bump version to 2026.7.0-alpha.0 2026-07-03 10:15:24 +00:00
かっこかり
c29a3d902b refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 (#17636)
* refactor(frontend): MkNote/MkNoteDetailedのロジックを統合

* refactor

* fix

* fix: 差分を解消

* fix lint

* fix types

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-03 19:14:48 +09:00
かっこかり
721b1b06a0 fix(backend/test): backend e2e test の beforeAll でdispose前にschema dropが走るのを修正 (#17653)
fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 15:57:39 +09:00
かっこかり
97e54a1ee8 fix(frontend): APIキーを指定するいくつかのフォームでautocompleteが発動する問題を修正 (#17655) 2026-07-03 14:58:58 +09:00
かっこかり
96d6a09ebc fix(backend/test): e2e testの固定待ち時間をポーリングに変更 (#17654)
* test(backend): timelines.ts の固定250ms待ちをポーリング化する (tiramiss-community/endolphin#47)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>

* test(backend): move.ts のflaky/低速sleepをポーリング化する (tiramiss-community/endolphin#49)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>

---------

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 10:46:04 +09:00
かっこかり
1eedf04d9a fix(frontend): デバイスサイズをスマートフォンに固定している場合はページヘッダーのアイコンを常に表示するように (#17590)
* fix(frontend): デバイスサイズをスマートフォンに固定している場合はページヘッダーのアイコンを常に表示するように

* Update Changelog

* Update Changelog

* fix

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-02 22:36:57 +09:00
ihoronir
2d67380f2a fix(backend): 同名の新規ハッシュタグが複数同時に作成されたとき、DBで発生した競合を適切にハンドリング (#17640)
* Hashtag Timeline のテストを復活させる

* HashtagService.ts を修正

* fix indent

* refactor

* Update CHANGELOG.md

* fix review
2026-07-02 09:48:26 +09:00
おさむのひと
174fb434cc fix: frontend-embedで見つけた型エラーの修正 (#17650) 2026-07-02 09:24:50 +09:00
greymoth
7e29f04287 fix(chat): don't send message while the IME is composing (#17646)
Co-authored-by: greymoth <246701683+greymoth-jp@users.noreply.github.com>
2026-07-01 10:06:16 +09:00
renovate[bot]
bf88122140 chore(deps): update [github actions] update dependencies [ci skip] (#17645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 13:11:23 +09:00
かっこかり
4daa1ffe05 chore(deps): 未使用依存パッケージを削除 (#17641)
chore(deps): 未使用依存パッケージを削除 (tiramiss-community/endolphin#20)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-06-29 23:08:09 +09:00
syuilo
62f8589c05 Update CONTRIBUTING.md 2026-06-28 17:52:08 +09:00
かっこかり
6193c35f9f fix: stats API の型に reactionCount が定義されていない問題を修正 (#17634)
* fix: stats API の型に reactionCount が定義されていない問題を修正

* Update Changelog
2026-06-28 01:49:00 +09:00
かっこかり
1220f05903 Update CHANGELOG.md 2026-06-28 01:02:03 +09:00
かっこかり
7544ebf7a3 Update CHANGELOG.md 2026-06-28 00:54:52 +09:00
かっこかり
ffe65caf10 Update CHANGELOG.md 2026-06-28 00:25:42 +09:00
かっこかり
4f993cef1b enhance: Set default Node.js version to v26 (#17623)
* enhance: Set default Node.js version to v26

* fix

* Update Changelog

* fix types

* drop node v22

* update changelog

* fix test

* update

* fix test

* fix test

* Revert "drop node v22"

This reverts commit bb4e011628.

* fix changelog

* attempt to fix test

* attempt to fix test

* fix: update re2 that supports node 26

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* restore 2fa e2e

* refactor

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* attempt to fix test
2026-06-28 00:06:49 +09:00
かっこかり
ba3fb4aa14 Merge branch 'master' into develop 2026-06-27 23:40:06 +09:00
かっこかり
0137b1c406 fix: update compatible node version on master (#17632)
Update package.json
2026-06-27 23:39:29 +09:00
かっこかり
797dec7d0e Update CHANGELOG.md 2026-06-27 23:36:35 +09:00
github-actions[bot]
42ff280163 Bump version to 2026.6.1-alpha.1 2026-06-27 13:08:35 +00:00
かっこかり
554339aaa1 deps: update dependencies (#17631)
* deps: update deps

* Update Changelog

* update dependencies (major)

* fix: broken lockfile on changelog-checker

* update vite

* update minimum working node version to 22.22.0

* update minimum working node version to 22.22.2

* fix types

* refactor: avoid using default import

* Revert "refactor: avoid using default import"

This reverts commit 5784df2ff0.

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-06-27 22:04:08 +09:00
syuilo
529c4d4d0e chore(dev): tweak heap snapshot table 2026-06-27 20:21:17 +09:00
syuilo
cec23e5756 enhance(dev): improve heap snapshot analysis (#17628)
* Update measure-memory.mts

* Update utility.mts

* Update backend-memory-report.mts

* tweak
2026-06-27 18:59:46 +09:00
syuilo
e9715aa45a enhance(dev): make heap snapshot downloadable 2026-06-27 14:45:30 +09:00
syuilo
dbbc5fc8c0 chore(dev): clean up unnecessary step 2026-06-27 11:55:49 +09:00
syuilo
228beff6c8 chore(dev): tweak backend-memory-report 2026-06-27 11:54:05 +09:00
syuilo
1faeb9b324 refactor(dev): use actions/download-artifact 2026-06-27 11:53:36 +09:00
syuilo
d1cac26cfb fix(dev): fix typo of report-api-diff.yml 2026-06-27 11:49:41 +09:00
syuilo
a107caef6b chore(dev): clean up unnecessary logs 2026-06-27 11:48:36 +09:00
syuilo
8fc3403d13 chore(dev): clean up unnecessary logs 2026-06-27 11:45:24 +09:00
renovate[bot]
a7503c225d chore(deps): update pnpm to v11.8.0 [security] (#17625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 09:55:24 +09:00
syuilo
3cd772e5a6 Merge branch 'master' into develop 2026-06-26 20:36:34 +09:00
syuilo
8af257929d enhance(frontend): drop pref migration (#17379)
clean pref migration
2026-06-26 20:35:37 +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
かっこかり
1068c6424f feat: コントロールパネルから二要素認証を解除できるように (#17614)
* enhance: コントロールパネルから二要素認証を解除できるように

* Update Changelog

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-06-26 19:33:25 +09:00
syuilo
9b32c6ffb8 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-06-26 17:52:57 +09:00
syuilo
038a2d0b81 refactor 2026-06-26 17:52:53 +09:00
github-actions[bot]
61f734d369 Bump version to 2026.6.1-alpha.0 2026-06-26 08:47:42 +00:00
おさむのひと
669889f749 chore: センシティブ判定を外部サービス扱いにする (#17570)
* chore: センシティブ判定を外部サービス扱いにする

* fix review

* regenerate

* fix review

* fix review

* regenerate

* fix CHANGELOG.md

* fix review

* add comment

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-06-26 17:47:00 +09:00
syuilo
a1f995cba8 fix(dev): fix measure-memory 2026-06-26 16:55:40 +09:00
syuilo
2747fd0348 chore(dev): tweak some workflows 2026-06-26 16:51:54 +09:00
syuilo
08ca482bcd chore(dev): tweak some workflows 2026-06-26 16:32:34 +09:00
syuilo
5cbdbd111b chore(dev): tweak backend-memory-report 2026-06-26 14:52:20 +09:00
syuilo
93fd491499 refactor(dev): extract heap snapshot logic
将来的にフロントエンドでもheap snaphotを集計したくなった時などのため
あとpairedDeltaSummaryを共通化
2026-06-26 11:57:07 +09:00
syuilo
73dca04885 chore(dev): refactor workflow js 2026-06-26 10:24:07 +09:00
syuilo
aad6ce835d fix(dev): follow up of 1c4bcd9b32 2026-06-26 10:16:48 +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
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
383 changed files with 18591 additions and 12843 deletions

View File

@@ -35,7 +35,7 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md``references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |

View File

@@ -21,7 +21,7 @@ note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック
`/quality-gate [scope]`
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない。
## Scope
@@ -120,4 +120,4 @@ Frontend ut: PASS (87/87)
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
- e2e / federation / Cypress は重いため除外し CI 側に委譲。
- e2e / federation / Playwright は重いため除外し CI 側に委譲。

View File

@@ -26,7 +26,7 @@ cp .github/misskey/test.yml .config/test.yml
補足:
- ルートの `pnpm start:test` (Cypress 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- ルートの `pnpm start:test` (Playwright 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- すでに `.config/test.yml` があれば各テストスクリプトの内部 `compile-config` で十分なので、追加で `pnpm --filter backend compile-config` を叩く必要はない
- `pnpm start:test` は backend e2e テスト (`pnpm --filter backend test:e2e`) の前提ではない (ポート競合の元になるため使わないこと)

View File

@@ -27,7 +27,7 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
- SCSS Modules / `--MI_THEME-*` `--MI-*` CSS 変数 / グローバル utility class (`_button` 等) → [references/knowledge/scss-modules.md](references/knowledge/scss-modules.md)
- `os.alert` / `os.confirm` / `os.popup` 等 UI ヘルパー (ブラウザ標準 `alert()` 直呼びは禁止) → [references/knowledge/os-api.md](references/knowledge/os-api.md)
- `*.stories.impl.ts` 併設規則 + 複数 story / argTypes / layout / action パターン → [references/knowledge/storybook.md](references/knowledge/storybook.md)
- frontend Vitest / Cypress E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
- frontend Vitest / Playwright E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
## 必ず最後に通る場所

View File

@@ -1,4 +1,4 @@
# Frontend テスト (Vitest / Cypress)
# Frontend テスト (Vitest / Playwright)
Misskey frontend のテスト構成。
@@ -13,11 +13,11 @@ pnpm --filter frontend test-and-coverage # カバレッジ付き
- 主な配置: `packages/frontend/test/*.test.ts` (例: `i18n.test.ts`, `theme.test.ts`, `is-birthday.test.ts`)
- ビルドツール周りなど対象コードと隣接させた方が分かりやすいテストは、コードと同じディレクトリに `*.test.ts` として置く (例: [packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts](../../../../../packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts))
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Cypress でカバー)
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Playwright でカバー)
## Cypress E2E
## Playwright E2E
Cypress**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
Playwright**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
```bash
# 1. テスト用 DB / Redis を起動 (テスト用ポート。開発用の compose.local-db.yml ではない)
@@ -29,15 +29,14 @@ cp .github/misskey/test.yml .config/test.yml
# 3. 全体ビルド
pnpm build
# 4. テストサーバー起動 + Cypress 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Cypress run
pnpm cy:open # 対話的に開く (サーバーは別途 pnpm start:test で起動しておく)
# 4. テストサーバー起動 + Playwright 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Playwright を実行
```
- 設定: ルート [cypress.config.ts](../../../../../cypress.config.ts)
- テスト本体は [cypress/](../../../../../cypress/) 配下
- 設定: ルート [packages/frontend/playwright.config.ts](../../../../../packages/frontend/playwright.config.ts) で、`packages/frontend/test/e2e/` 配下の `*.spec.ts` を対象にする
- テスト本体は [packages/frontend/test/e2e/](../../../../../packages/frontend/test/e2e/) 配下に配置する。
新規 frontend 機能の E2E は Cypress に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
新規 frontend 機能の E2E は Playwright に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
## Storybook (視覚確認 + Chromatic 視覚回帰)
@@ -55,6 +54,6 @@ pnpm --filter frontend build-storybook # 静的ビルド
frontend のテスト種別で DB / Redis の要否が違う:
- **Vitest (unit)** — DB 不要。ロジック / コンポーネント単体のテストで backend に繋がない (CI の `vitest` ジョブにも `services:` は無い)
- **Cypress (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Cypress E2E の手順を参照)
- **Playwright (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Playwright E2E の手順を参照)
開発用の `compose.local-db.yml` (db `5432` / redis `6379`) は **テストには使わない**。テスト用の `packages/backend/test/compose.yml` (`54312` / `56312`) とはポートが異なり、混同すると接続できない。

11
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,11 @@
language: "ja-JP"
tone_instructions: "常に丁寧語(です・ます調)を用い、敬意のある表現で応答してください。人格や能力への評価、皮肉、威圧的・高圧的・命令的な表現は避けてください。指摘では問題点と影響を明確にし、理由を簡潔に説明したうえで、具体的な改善案を提案してください。不確実な事項は推測と明示し、確認を促してください。重大なバグやセキュリティ上の懸念は重要度と根拠を明確に伝えてください。"
reviews:
profile: "chill"
high_level_summary: false
poem: true
auto_review:
enabled: true
drafts: false
base_branches:
- "develop"

View File

@@ -233,15 +233,24 @@ proxyBypassHosts:
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# Log settings
# logging:
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
# format: pretty
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
# level: info
# # The longest matching logger domain takes precedence over the global level.
# domains:
# core.boot: info
# queue: error
# queue.deliver: debug
# db.sql: off
# sql:
# # Outputs query parameters during SQL execution to the log.
# # default: false

View File

@@ -303,8 +303,88 @@ id: 'aidx'
#sentryForBackend:
# enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
# ┌─────────┐
#───┘ Tracing └────────────────────────────────────────────────
# OpenTelemetry distributed tracing (Traces only, opt-in).
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
#
# Standard OTEL_* environment variables are honored for values omitted below:
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
#
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
# - sampleRate below is ignored; sampling follows sentryForBackend.options
# tracesSampleRate / tracesSampler.
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
# - sentry-trace / baggage propagation to outbound remote requests is disabled
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
# you intentionally want Sentry trace propagation for your deployment.
#otelForBackend:
# endpoint: 'http://localhost:4318/v1/traces'
# #headers:
# # authorization: 'Bearer xxxxx'
# #sampleRate: 1.0
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
# # overhead when database-level diagnostics are not needed.
# #capturePgSpans: false
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
# # disabled by default because non-parameterized queries can expose values to
# # the OTLP Collector.
# #capturePgStatement: false
# # Include PostgreSQL connection-pool spans with an existing parent span.
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
# # from connection churn.
# #capturePgConnectionSpans: false
# # Record Redis command spans. Disabled by default because subscribing to the
# # ioredis diagnostics channel adds work to every Redis command, including
# # commands without a parent span. Enable for development diagnostics or when
# # the added overhead is acceptable in production.
# #captureRedisCommandSpans: false
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
# # connection churn; these spans are recorded even without an HTTP or job
# # parent span.
# #captureRedisConnectionSpans: false
# # Record Redis commands without an HTTP or job parent span. Disabled by
# # default because queue polling and background work can produce many root
# # traces; only applies when captureRedisCommandSpans is enabled.
# #captureRedisRootSpans: false
# #resourceAttributes:
# # deployment.environment: 'production'
# #propagateTraceToRemote: false
# # Queue worker spans are independent traces linked to their enqueuer by default.
# # Set to 'parent' to make them child spans instead. This can create very large
# # traces for high-fan-out queues such as federation delivery.
# #jobTraceContextMode: 'link'
#sentryForFrontend:
# vueIntegration:
@@ -383,9 +463,8 @@ proxyBypassHosts:
# For security reasons, uploading attachments from the intranet is prohibited,
# but exceptions can be made from the following settings. Default value is "undefined".
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
#allowedPrivateNetworks:
# - '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
@@ -395,6 +474,16 @@ proxyBypassHosts:
# Log settings
# logging:
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
# format: pretty
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
# level: info
# # The longest matching logger domain takes precedence over the global level.
# domains:
# core.boot: info
# queue: error
# queue.deliver: debug
# db.sql: off
# sql:
# # Outputs query parameters during SQL execution to the log.
# # default: false

View File

@@ -218,9 +218,8 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
allowedPrivateNetworks: [
'127.0.0.1/32'
]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -5,7 +5,7 @@
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "22.15.0"
"version": "22.23.1"
},
"ghcr.io/devcontainers-extra/features/pnpm:2": {
"version": "10.10.0"

View File

@@ -205,9 +205,8 @@ proxyBypassHosts:
# Media Proxy
#mediaProxy: https://example.com/proxy
allowedPrivateNetworks: [
'127.0.0.1/32'
]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -12,4 +12,4 @@ pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
pnpm exec cypress install
pnpm --filter frontend exec playwright install --with-deps chromium

View File

@@ -37,6 +37,3 @@ updates:
typescript-eslint:
patterns:
- "@typescript-eslint/*"
tensorflow:
patterns:
- "@tensorflow/*"

2
.github/labeler.yml vendored
View File

@@ -16,7 +16,7 @@
'packages/frontend:test':
- any:
- changed-files:
- any-glob-to-any-file: ['cypress/**/*']
- any-glob-to-any-file: ['packages/frontend/test/**/*']
'packages/sw':
- any:

View File

@@ -1 +1 @@
22.15.0
22.22.2

View File

@@ -0,0 +1,239 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createRequire } from 'node:module';
import { copyFile, rm, 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 { MemorySample } from '../../packages/backend/scripts/measure-memory.mts';
const phases = ['afterGc'] as const;
export type MemoryReport = {
timestamp: string;
sampleCount: number;
aggregation: string;
summary: Record<typeof phases[number], {
memoryUsage: Record<string, number>;
heapSnapshot?: heapSnapshotUtil.HeapSnapshotData;
}>;
samples: (MemorySample & {
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', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
const heapSnapshotLabels = ['base', 'head'] as const;
const HEAP_SNAPSHOT_WORK_DIRS = {
base: resolve('base-heap-snapshots'),
head: resolve('head-heap-snapshots'),
};
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
base: resolve('base-heap-snapshot.heapsnapshot'),
head: resolve('head-heap-snapshot.heapsnapshot'),
};
// Use the head checkout's measurement harness for both targets so only the built backend differs.
const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts');
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 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 heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
samples,
sample => sample.phases[phase].heapSnapshot,
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
);
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
}
return summary;
}
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
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_BACKEND_DIR: resolve(repoDir, 'packages/backend'),
} as NodeJS.ProcessEnv;
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
if (options.heapSnapshotSavePath != null) measureEnv.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH = options.heapSnapshotSavePath;
const stdout = await util.run('node', [MEASURE_MEMORY_SCRIPT], {
cwd: repoDir,
env: measureEnv,
});
return JSON.parse(stdout) as MemorySample;
}
function heapSnapshotPath(label: typeof heapSnapshotLabels[number], round: number) {
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
}
function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const medianTotal = summary.afterGc.heapSnapshot?.categories?.total;
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
let selected: { round: number; distance: number } | null = null;
for (const sample of samples) {
const total = sample.phases.afterGc.heapSnapshot?.categories?.total;
if (total == null || !Number.isFinite(total)) continue;
const distance = Math.abs(total - medianTotal);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
selected = {
round: sample.round,
distance,
};
}
}
return selected?.round ?? null;
}
async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const round = selectRepresentativeHeapSnapshotRound(samples, summary);
if (round == null) return;
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\n`);
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
}
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();
for (const label of heapSnapshotLabels) {
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true });
}
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 heapSnapshotLabels) {
await genSample(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 label of order) {
const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) };
const sample = await genSample(label, reports[label].dir, round, options);
reports[label].samples.push({
...sample,
round,
});
}
}
const summaries = {
base: summarizeSamples(reports.base.samples),
head: summarizeSamples(reports.head.samples),
};
for (const label of heapSnapshotLabels) {
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
}
for (const label of heapSnapshotLabels) {
const report = {
timestamp: new Date().toISOString(),
sampleCount: reports[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
startedAt,
},
summary: summaries[label],
samples: reports[label].samples,
};
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,183 @@
/*
* 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 './backend-diagnostics.inspect.mts';
const [baseFile, headFile, outputFile] = process.argv.slice(2);
const memoryReportPhases = [
{
key: 'afterGc',
title: 'After GC',
},
] as const;
const memoryMetrics = [
'HeapUsed',
'Pss',
'USS',
'External',
] as const;
function formatMemoryMb(valueKiB: number | null | undefined) {
if (valueKiB == null) return '-';
return `${util.formatNumber(valueKiB / 1000)} MB`;
}
function formatMemoryMetricName(metric: typeof memoryMetrics[number]) {
return metric === 'Pss' ? 'PSS' : metric;
}
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
const memoryUsage = sample.phases[phase].memoryUsage;
if (metric !== 'USS') return memoryUsage[metric];
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
}
function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
}
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
if (metric !== 'USS') return report.summary[phase].memoryUsage[metric];
return util.median(getSampleValues(report, phase, metric));
}
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
const values = getSampleValues(report, 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(deltaKiB: number) {
return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold
}
for (const metric of memoryMetrics) {
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 = `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`);
}
return lines.join('\n');
}
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');
}
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
const lines = [
'## ⚙️ Backend Diagnostics Report',
'',
];
//const summary = measurementSummary(base, head);
//if (summary != null) {
// lines.push(summary);
// lines.push('');
//}
for (const phase of memoryReportPhases) {
lines.push(`### Memory: ${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 baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim();
const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim();
lines.push(`You can download the representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`);
lines.push('');
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
return ((headValue - baseValue) * 100) / baseValue;
}
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const delta = headValue - baseValue;
if (delta <= 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 delta > combinedSpread * 3;
}
const warningMetric = 'Pss';
const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
lines.push('');
}
await writeFile(outputFile, `${lines.join('\n')}\n`);

View File

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

View File

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

View File

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

View File

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

570
.github/scripts/chrome.mts vendored Normal file
View File

@@ -0,0 +1,570 @@
/*
* 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 type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
import * as util from './utility.mts';
export type NetworkRequest = {
requestId: string;
url: string;
method: string;
resourceType: string;
startedAt: number;
documentUrl?: string;
requestHeaders?: Record<string, string>;
requestBody?: string;
hasRequestBody: boolean;
status?: number;
statusText?: string;
mimeType?: string;
responseHeaders?: Record<string, string>;
protocol?: string;
remoteIPAddress?: string;
remotePort?: number;
encodedDataLength: number;
decodedBodyLength: number;
fromDiskCache: boolean;
fromServiceWorker: boolean;
finished: boolean;
failed: boolean;
errorText?: string;
};
export type WebSocketConnection = {
requestId: string;
url: string;
createdAt: number;
handshakeRequestHeaders?: Record<string, string>;
handshakeResponseStatus?: number;
handshakeResponseStatusText?: string;
handshakeResponseHeaders?: Record<string, string>;
closedAt?: number;
sentFrameCount: number;
receivedFrameCount: number;
sentBytes: number;
receivedBytes: number;
errorCount: number;
};
export type NetworkSummary = {
requestCount: number;
webSocketConnectionCount: number;
webSocketSentBytes: number;
webSocketReceivedBytes: number;
finishedRequestCount: number;
failedRequestCount: number;
cachedRequestCount: number;
serviceWorkerRequestCount: number;
totalEncodedBytes: number;
totalDecodedBodyBytes: number;
sameOriginEncodedBytes: number;
thirdPartyEncodedBytes: number;
byResourceType: Record<string, {
requests: number;
encodedBytes: number;
decodedBodyBytes: number;
}>;
largestRequests: {
url: string;
method: string;
resourceType: string;
status?: number;
encodedBytes: number;
decodedBodyBytes: number;
}[];
failedRequests: {
url: string;
method: string;
resourceType: string;
errorText?: string;
status?: number;
}[];
};
export type TabMemory = {
totalBytes: number;
};
export type BrowserDiagnostics = {
pageErrorCount: number;
console: Record<'log' | 'warning' | 'error' | 'info', number>;
};
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select));
return {
pageErrorCount: median(sample => sample.pageErrorCount),
console: {
log: median(sample => sample.console.log),
warning: median(sample => sample.console.warning),
error: median(sample => sample.console.error),
info: median(sample => sample.console.info),
},
};
}
export type BrowserMeasurement = {
label: string;
timestamp: string;
url: string;
scenario: string;
diagnostics: BrowserDiagnostics;
durationMs: number;
network: NetworkSummary;
performance: {
cdpMetrics: Record<string, number>;
runtimeHeap?: {
usedSize: number;
totalSize: number;
};
tabMemory: TabMemory;
webVitals: {
firstPaintMs?: number;
firstContentfulPaintMs?: number;
domContentLoadedEventEndMs?: number;
loadEventEndMs?: number;
longTaskCount: number;
longTaskDurationMs: number;
maxLongTaskDurationMs: number;
resourceEntryCount: number;
domElements: number;
};
};
heapSnapshot: HeapSnapshotData;
};
type PlaywrightModule = typeof import('playwright');
const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url));
function loadPlaywright(): PlaywrightModule {
return requireFromFrontend('playwright') as PlaywrightModule;
}
function normalizeHeaders(headers: Record<string, unknown> | undefined) {
if (headers == null) return undefined;
const normalized = {} as Record<string, string>;
for (const [key, value] of Object.entries(headers)) {
normalized[key] = String(value);
}
return normalized;
}
function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) {
if (frame?.payloadData == null) return 0;
if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8');
return Buffer.byteLength(frame.payloadData, 'base64');
}
type PlaywrightBrowserOptions = {
scenarioTimeoutMs: number;
baseUrl: string;
};
export class HeadlessChromeController {
public networkRequests: NetworkRequest[] = [];
public webSocketConnections: WebSocketConnection[] = [];
private readonly diagnostics = {
pageErrorCount: 0,
console: {} as Record<string, number | undefined>,
};
private readonly browser: Browser;
private readonly context: BrowserContext;
public readonly page: Page;
private readonly cdp: CDPSession;
private pendingNetworkDetailReads: Promise<void>[] = [];
private constructor(
browser: Browser,
context: BrowserContext,
page: Page,
cdp: CDPSession,
options: PlaywrightBrowserOptions,
) {
this.browser = browser;
this.context = context;
this.page = page;
this.cdp = cdp;
this.page.setDefaultTimeout(options.scenarioTimeoutMs);
this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs);
this.page.on('pageerror', () => {
this.diagnostics.pageErrorCount++;
});
this.page.on('console', message => {
if (this.diagnostics.console[message.type()] == null) {
this.diagnostics.console[message.type()] = 1;
} else {
this.diagnostics.console[message.type()]++;
}
});
}
static async create(label: string, options: PlaywrightBrowserOptions): Promise<HeadlessChromeController> {
process.stderr.write(`[${label}] Launching Playwright Chromium\n`);
const { chromium } = loadPlaywright();
const browser = await chromium.launch({
channel: 'chromium',
headless: true,
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--metrics-recording-only',
'--no-first-run',
'--no-default-browser-check',
'--no-sandbox',
],
});
try {
const context = await browser.newContext({
baseURL: options.baseUrl,
locale: 'en-US',
});
await context.addInitScript(() => {
// @ts-expect-error Test-only runtime hint consumed by Misskey frontend code.
window.isPlaywright = true;
});
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
return new HeadlessChromeController(browser, context, page, cdp, options);
} catch (error) {
await browser.close().catch(() => undefined);
throw error;
}
}
static async with<T>(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
const browser = await HeadlessChromeController.create(label, options);
try {
return await callback(browser);
} finally {
await browser.close();
}
}
public async enableNetworkTracking() {
const requests = new Map<string, NetworkRequest>();
const webSockets = new Map<string, WebSocketConnection>();
const readRequestBody = (row: NetworkRequest) => {
if (!row.hasRequestBody || row.requestBody != null) return;
const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', {
requestId: row.requestId,
}).then(result => {
row.requestBody = result.postData;
}).catch(() => {
// Some requests expose hasPostData but no longer have retrievable body data.
});
this.pendingNetworkDetailReads.push(pending);
};
this.cdp.on('Network.requestWillBeSent', params => {
if (params.request?.url == null) return;
const row: NetworkRequest = {
requestId: params.requestId,
url: params.request.url,
method: params.request.method ?? 'GET',
resourceType: params.type ?? 'Other',
startedAt: params.timestamp ?? 0,
documentUrl: params.documentURL,
requestHeaders: normalizeHeaders(params.request.headers),
requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined,
hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string',
encodedDataLength: 0,
decodedBodyLength: 0,
fromDiskCache: false,
fromServiceWorker: false,
finished: false,
failed: false,
};
requests.set(params.requestId, row);
this.networkRequests.push(row);
});
this.cdp.on('Network.webSocketCreated', params => {
if (params.requestId == null || params.url == null) return;
const row: WebSocketConnection = {
requestId: params.requestId,
url: params.url,
createdAt: params.timestamp ?? 0,
sentFrameCount: 0,
receivedFrameCount: 0,
sentBytes: 0,
receivedBytes: 0,
errorCount: 0,
};
webSockets.set(params.requestId, row);
this.webSocketConnections.push(row);
});
this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers);
});
this.cdp.on('Network.webSocketHandshakeResponseReceived', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.handshakeResponseStatus = params.response?.status;
row.handshakeResponseStatusText = params.response?.statusText;
row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers);
});
this.cdp.on('Network.webSocketFrameSent', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.sentFrameCount += 1;
row.sentBytes += webSocketFramePayloadBytes(params.response);
});
this.cdp.on('Network.webSocketFrameReceived', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.receivedFrameCount += 1;
row.receivedBytes += webSocketFramePayloadBytes(params.response);
});
this.cdp.on('Network.webSocketFrameError', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.errorCount += 1;
});
this.cdp.on('Network.webSocketClosed', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.closedAt = params.timestamp ?? 0;
});
this.cdp.on('Network.responseReceived', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.status = params.response?.status;
row.statusText = params.response?.statusText;
row.mimeType = params.response?.mimeType;
row.responseHeaders = normalizeHeaders(params.response?.headers);
row.protocol = params.response?.protocol;
row.remoteIPAddress = params.response?.remoteIPAddress;
row.remotePort = params.response?.remotePort;
row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders);
row.fromDiskCache = params.response?.fromDiskCache === true;
row.fromServiceWorker = params.response?.fromServiceWorker === true;
});
this.cdp.on('Network.dataReceived', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.decodedBodyLength += params.dataLength ?? 0;
row.encodedDataLength += params.encodedDataLength ?? 0;
});
this.cdp.on('Network.loadingFinished', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.finished = true;
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
readRequestBody(row);
});
this.cdp.on('Network.loadingFailed', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.failed = true;
row.finished = true;
row.errorText = params.errorText;
readRequestBody(row);
});
await this.cdp.send('Network.enable');
await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
await this.cdp.send('Network.setBypassServiceWorker', { bypass: true });
await this.cdp.send('Page.enable');
await this.cdp.send('Runtime.enable');
await this.cdp.send('Performance.enable');
}
public async waitForNetworkDetails() {
let settledCount = 0;
while (settledCount < this.pendingNetworkDetailReads.length) {
const pending = this.pendingNetworkDetailReads.slice(settledCount);
settledCount = this.pendingNetworkDetailReads.length;
await Promise.allSettled(pending);
}
}
public async evaluate<T>(expression: string, timeoutMs = 30_000): Promise<T> {
return await Promise.race([
this.page.evaluate(expression),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()),
]) as T;
}
public collectDiagnostics(): BrowserDiagnostics {
return {
pageErrorCount: this.diagnostics.pageErrorCount,
console: {
log: this.diagnostics.console.log ?? 0,
warning: this.diagnostics.console.warning ?? 0,
error: this.diagnostics.console.error ?? 0,
info: this.diagnostics.console.info ?? 0,
},
};
}
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
const tabMemory = await this.collectTabMemory();
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
const navigation = performance.getEntriesByType('navigation')[0];
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
const longTasks = performance.getEntriesByType('longtask');
const resourceEntries = performance.getEntriesByType('resource');
return {
firstPaintMs: paintEntries['first-paint'],
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
loadEventEndMs: navigation?.loadEventEnd,
longTaskCount: longTasks.length,
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
resourceEntryCount: resourceEntries.length,
domElements: document.getElementsByTagName('*').length,
};
})()`);
return {
cdpMetrics,
runtimeHeap,
tabMemory,
webVitals,
};
}
public async collectTabMemory(): Promise<TabMemory> {
const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => {
const measureMemory = performance.measureUserAgentSpecificMemory;
if (typeof measureMemory !== 'function') return {};
const result = await measureMemory.call(performance);
return { bytes: result.bytes };
})()`, 60_000);
const userAgentSpecificBytes = userAgentSpecificMemory?.bytes;
if (!Number.isFinite(userAgentSpecificBytes)) {
throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes');
}
return {
totalBytes: userAgentSpecificBytes as number,
};
}
public async takeHeapSnapshot(savePath?: string) {
const chunks: string[] = [];
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => {
chunks.push(params.chunk);
});
await this.cdp.send('HeapProfiler.enable');
await this.cdp.send('HeapProfiler.collectGarbage');
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
const content = chunks.join('');
if (savePath != null) {
await writeFile(savePath, content);
}
return JSON.parse(content);
}
public async close() {
await this.cdp.detach().catch(() => undefined);
await this.context.close().catch(() => undefined);
await this.browser.close().catch(() => undefined);
}
}
function isMeasurableRequest(row: NetworkRequest) {
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
}
export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary {
const origin = new URL(baseUrl).origin;
const rows = requestRows.filter(isMeasurableRequest);
const byResourceType = {} as NetworkSummary['byResourceType'];
for (const row of rows) {
const summary = byResourceType[row.resourceType] ?? {
requests: 0,
encodedBytes: 0,
decodedBodyBytes: 0,
};
summary.requests += 1;
summary.encodedBytes += row.encodedDataLength;
summary.decodedBodyBytes += row.decodedBodyLength;
byResourceType[row.resourceType] = summary;
}
function isSameOrigin(url: string) {
try {
return new URL(url).origin === origin;
} catch {
return false;
}
}
return {
requestCount: rows.length,
webSocketConnectionCount: webSocketRows == null
? rows.filter(row => row.resourceType === 'WebSocket').length
: webSocketRows.length,
webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0,
webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0,
finishedRequestCount: rows.filter(row => row.finished).length,
failedRequestCount: rows.filter(row => row.failed).length,
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
sameOriginEncodedBytes: rows
.filter(row => isSameOrigin(row.url))
.reduce((sum, row) => sum + row.encodedDataLength, 0),
thirdPartyEncodedBytes: rows
.filter(row => !isSameOrigin(row.url))
.reduce((sum, row) => sum + row.encodedDataLength, 0),
byResourceType,
largestRequests: rows
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
.slice(0, 15)
.map(row => ({
url: row.url,
method: row.method,
resourceType: row.resourceType,
status: row.status,
encodedBytes: row.encodedDataLength,
decodedBodyBytes: row.decodedBodyLength,
})),
failedRequests: rows
.filter(row => row.failed)
.map(row => ({
url: row.url,
method: row.method,
resourceType: row.resourceType,
errorText: row.errorText,
status: row.status,
})),
};
}

View File

@@ -0,0 +1,279 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { copyFile, mkdir, rm, 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 { HeadlessChromeController, summarizeBrowserDiagnostics, summarizeNetwork } from './chrome.mts';
import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts';
import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts';
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812';
const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1);
const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
const heapSnapshotWorkDirs = {
base: resolve('frontend-browser-base-heap-snapshots'),
head: resolve('frontend-browser-head-heap-snapshots'),
} as const;
const heapSnapshotOutputPaths = {
base: resolve('base-heap-snapshot.heapsnapshot'),
head: resolve('head-heap-snapshot.heapsnapshot'),
} as const;
type BrowserMeasurementSample = BrowserMeasurement & {
round: number;
networkRequests: NetworkRequest[];
};
type BrowserMetricsReport = {
label: string;
timestamp: string;
url: string;
scenario: string;
sampleCount: number;
aggregation: 'median';
summary: BrowserMeasurement;
samples: BrowserMeasurementSample[];
};
async function runSignupAndPostScenario(chrome: HeadlessChromeController) {
const page = chrome.page;
const noteText = `Frontend browser metrics ${Date.now()}`;
await visitHome(page, baseUrl);
await signupThroughUi(page, { username: 'alice', password: 'password' });
await closeUserSetupDialog(page);
await postNote(page, noteText, 10_000);
await util.sleep(1000);
}
function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return defaultValue;
return util.median(finiteValues);
}
function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
const medianValue = finiteMedian(samples.map(getValue));
let selected: { sample: BrowserMeasurementSample; distance: number } | null = null;
for (const sample of samples) {
const value = getValue(sample);
if (!Number.isFinite(value)) continue;
const distance = Math.abs(value - medianValue);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) {
selected = {
sample,
distance,
};
}
}
return selected?.sample ?? samples[0];
}
function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) {
return {
requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)),
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)),
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)),
};
}
function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary {
const resourceTypes = new Set<string>();
for (const sample of samples) {
for (const resourceType of Object.keys(sample.network.byResourceType)) {
resourceTypes.add(resourceType);
}
}
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
const byResourceType = {} as NetworkSummary['byResourceType'];
for (const resourceType of resourceTypes) {
byResourceType[resourceType] = summarizeResourceType(samples, resourceType);
}
return {
requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)),
webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)),
webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)),
webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)),
finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)),
failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)),
cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)),
serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)),
totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)),
totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)),
sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)),
thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)),
byResourceType,
largestRequests: representative.network.largestRequests,
failedRequests: representative.network.failedRequests,
};
}
function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] {
const cdpMetricKeys = new Set<string>();
for (const sample of samples) {
for (const key of Object.keys(sample.performance.cdpMetrics)) {
cdpMetricKeys.add(key);
}
}
const cdpMetrics = {} as Record<string, number>;
for (const key of cdpMetricKeys) {
cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key]));
}
const webVitalKeys = [
'firstPaintMs',
'firstContentfulPaintMs',
'domContentLoadedEventEndMs',
'loadEventEndMs',
'longTaskCount',
'longTaskDurationMs',
'maxLongTaskDurationMs',
'resourceEntryCount',
'domElements',
] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[];
const webVitals = {} as BrowserMeasurement['performance']['webVitals'];
for (const key of webVitalKeys) {
webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key]));
}
return {
cdpMetrics,
runtimeHeap: {
usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)),
totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)),
},
tabMemory: {
totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)),
},
webVitals,
};
}
function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) {
const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
samples,
sample => sample.heapSnapshot,
{ breakdownTopN: heapSnapshotBreakdownTopN },
);
if (summary == null) throw new Error('No heap snapshot samples');
return summary;
}
function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport {
if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`);
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
const summary: BrowserMeasurement = {
label,
timestamp: new Date().toISOString(),
url: baseUrl,
scenario: representative.scenario,
diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)),
durationMs: finiteMedian(samples.map(sample => sample.durationMs)),
network: summarizeNetworkSamples(samples),
performance: summarizePerformanceSamples(samples),
heapSnapshot: summarizeHeapSnapshotSamples(samples),
};
return {
label,
timestamp: new Date().toISOString(),
url: baseUrl,
scenario: representative.scenario,
sampleCount: samples.length,
aggregation: 'median',
summary,
samples,
};
}
async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) {
await util.prepareInstance(baseUrl);
return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => {
await chrome.enableNetworkTracking();
const startedAt = Date.now();
await runSignupAndPostScenario(chrome);
const durationMs = Date.now() - startedAt;
await chrome.waitForNetworkDetails();
const performance = await chrome.collectPerformance();
const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath);
const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN });
const measurement: BrowserMeasurementSample = {
label,
round,
timestamp: new Date().toISOString(),
url: baseUrl,
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
diagnostics: chrome.collectDiagnostics(),
durationMs,
network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections),
networkRequests: chrome.networkRequests,
performance,
heapSnapshot,
};
return measurement;
});
}
function heapSnapshotPath(label: 'base' | 'head', round: number) {
return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`);
}
async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) {
const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total);
await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]);
process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`);
await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true });
}
async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string) {
let server: ReturnType<typeof util.startServer> | null = null;
try {
server = util.startServer(label, repoDir);
await util.waitForServer(baseUrl, server);
await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true });
await mkdir(heapSnapshotWorkDirs[label], { recursive: true });
const samples: BrowserMeasurementSample[] = [];
for (let round = 1; round <= sampleCount; round++) {
process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`);
samples.push(await measureSample(
label,
round,
heapSnapshotPath(label, round),
));
}
const report = summarizeSamples(label, samples);
await writeFile(outputPath, JSON.stringify(report, null, '\t'));
process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`);
await saveRepresentativeHeapSnapshot(label, report);
} finally {
if (server != null) await util.stopServer(server);
}
}
async function main() {
await genReport('base', resolve(baseDirArg), resolve(baseOutputArg));
await genReport('head', resolve(headDirArg), resolve(headOutputArg));
}
await main();

View File

@@ -0,0 +1,446 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import * as util from './utility.mts';
import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-diagnostics.render-md.mts';
import type { NetworkRequest } from './chrome.mts';
type DiffDirection = 'added' | 'removed';
type RequestDiff = {
direction: DiffDirection;
round: number;
baseCount: number;
headCount: number;
request: NetworkRequest;
};
function escapeHtml(value: unknown) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeAttribute(value: unknown) {
return escapeHtml(value);
}
function isHttpRequest(request: NetworkRequest) {
try {
const { protocol } = new URL(request.url);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
function requestKey(request: NetworkRequest) {
return [
request.method,
request.resourceType,
request.url,
].join('\u0000');
}
function groupRequests(requests: NetworkRequest[] | undefined) {
const grouped = new Map<string, NetworkRequest[]>();
for (const request of requests ?? []) {
if (!isHttpRequest(request)) continue;
const key = requestKey(request);
const rows = grouped.get(key) ?? [];
rows.push(request);
grouped.set(key, rows);
}
return grouped;
}
function byRound(samples: BrowserMeasurementSample[]) {
return new Map(samples.map(sample => [sample.round, sample]));
}
function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) {
const baseRequests = groupRequests(baseSample?.networkRequests);
const headRequests = groupRequests(headSample?.networkRequests);
const keys = [...new Set([
...baseRequests.keys(),
...headRequests.keys(),
])].toSorted();
const diffs: RequestDiff[] = [];
for (const key of keys) {
const baseRows = baseRequests.get(key) ?? [];
const headRows = headRequests.get(key) ?? [];
if (headRows.length > baseRows.length) {
for (const request of headRows.slice(baseRows.length)) {
diffs.push({
direction: 'added',
round,
baseCount: baseRows.length,
headCount: headRows.length,
request,
});
}
} else if (baseRows.length > headRows.length) {
for (const request of baseRows.slice(headRows.length)) {
diffs.push({
direction: 'removed',
round,
baseCount: baseRows.length,
headCount: headRows.length,
request,
});
}
}
}
return diffs;
}
function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const baseSamples = byRound(base.samples);
const headSamples = byRound(head.samples);
const rounds = [...new Set([
...baseSamples.keys(),
...headSamples.keys(),
])].toSorted((a, b) => a - b);
return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round)));
}
function formatMaybeJson(value: string | undefined) {
if (value == null || value === '') return null;
try {
return JSON.stringify(JSON.parse(value), null, '\t');
} catch {
return value;
}
}
function formatHeaders(headers: Record<string, string> | undefined) {
if (headers == null || Object.keys(headers).length === 0) return null;
return JSON.stringify(headers, null, '\t');
}
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
const counts = new Map<T, number>();
for (const diff of diffs) {
counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1);
}
return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) {
const added = diffs.filter(diff => diff.direction === 'added').length;
const removed = diffs.filter(diff => diff.direction === 'removed').length;
const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => `
<tr>
<td>${escapeHtml(type)}</td>
<td class="num">${util.formatNumber(count)}</td>
</tr>`).join('');
return `
<section class="summary">
<div>
<span class="label">Base samples</span>
<strong>${util.formatNumber(base.sampleCount)}</strong>
</div>
<div>
<span class="label">Head samples</span>
<strong>${util.formatNumber(head.sampleCount)}</strong>
</div>
<div>
<span class="label">Added in Head</span>
<strong class="added-text">${util.formatNumber(added)}</strong>
</div>
<div>
<span class="label">Removed in Head</span>
<strong class="removed-text">${util.formatNumber(removed)}</strong>
</div>
</section>
${typeRows === '' ? '' : `
<section>
<h2>Diffs by Resource Type</h2>
<table>
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
<tbody>${typeRows}
</tbody>
</table>
</section>`}`;
}
function renderDetails(title: string, content: string | null, open = false) {
if (content == null || content === '') return '';
return `
<details${open ? ' open' : ''}>
<summary>${escapeHtml(title)}</summary>
<pre>${escapeHtml(content)}</pre>
</details>`;
}
function renderRequest(diff: RequestDiff) {
const { request } = diff;
const requestBody = formatMaybeJson(request.requestBody);
const requestHeaders = formatHeaders(request.requestHeaders);
const responseHeaders = formatHeaders(request.responseHeaders);
const bodyNote = requestBody == null && request.hasRequestBody === true
? '<p class="empty">Request body was present but could not be retrieved from CDP.</p>'
: '';
return `
<article class="request ${diff.direction}">
<header>
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
<span class="method">${escapeHtml(request.method)}</span>
<span class="type">${escapeHtml(request.resourceType)}</span>
<span class="status">${escapeHtml(request.status ?? '-')}</span>
</header>
<a class="url" href="${escapeAttribute(request.url)}">${escapeHtml(request.url)}</a>
<dl>
<div><dt>Round</dt><dd>${util.formatNumber(diff.round)}</dd></div>
<div><dt>Base count</dt><dd>${util.formatNumber(diff.baseCount)}</dd></div>
<div><dt>Head count</dt><dd>${util.formatNumber(diff.headCount)}</dd></div>
<div><dt>Encoded</dt><dd>${util.formatBytes(request.encodedDataLength ?? 0)}</dd></div>
<div><dt>Decoded body</dt><dd>${util.formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
<div><dt>MIME</dt><dd>${escapeHtml(request.mimeType ?? '-')}</dd></div>
<div><dt>Protocol</dt><dd>${escapeHtml(request.protocol ?? '-')}</dd></div>
<div><dt>Remote</dt><dd>${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}</dd></div>
<div><dt>Failed</dt><dd>${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}</dd></div>
</dl>
${bodyNote}
${renderDetails('Request body', requestBody, requestBody != null)}
${renderDetails('Request headers', requestHeaders)}
${renderDetails('Response headers', responseHeaders)}
</article>`;
}
function renderRound(round: number, diffs: RequestDiff[]) {
const added = diffs.filter(diff => diff.direction === 'added').length;
const removed = diffs.filter(diff => diff.direction === 'removed').length;
return `
<section>
<h2>Round ${util.formatNumber(round)}</h2>
<p>${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed</p>
<div class="requests">
${diffs.map(renderRequest).join('\n')}
</div>
</section>`;
}
function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const diffs = diffReports(base, head);
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
const generatedAt = new Date().toISOString();
const content = diffs.length === 0
? '<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>'
: rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frontend Browser Network Request Diff</title>
<style>
:root {
color-scheme: light dark;
--bg: #f7f7f8;
--fg: #202124;
--muted: #5f6368;
--card: #ffffff;
--border: #dfe1e5;
--added: #137333;
--added-bg: #e6f4ea;
--removed: #a50e0e;
--removed-bg: #fce8e6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111315;
--fg: #e8eaed;
--muted: #bdc1c6;
--card: #1b1d20;
--border: #3c4043;
--added-bg: #17351f;
--removed-bg: #3c1f1d;
}
}
body {
margin: 0;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
h1 {
font-size: 24px;
margin: 0 0 8px;
}
h2 {
font-size: 18px;
margin: 32px 0 8px;
}
.meta {
color: var(--muted);
margin: 0 0 24px;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin: 24px 0;
}
.summary > div, .request, table {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
}
.summary > div {
padding: 14px;
}
.label {
display: block;
color: var(--muted);
font-size: 12px;
}
.summary strong {
display: block;
font-size: 24px;
margin-top: 4px;
}
.added-text {
color: var(--added);
}
.removed-text {
color: var(--removed);
}
table {
border-collapse: collapse;
width: 100%;
overflow: hidden;
}
th, td {
border-bottom: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
}
th {
color: var(--muted);
font-weight: 600;
}
.num {
text-align: right;
}
.requests {
display: grid;
gap: 12px;
}
.request {
padding: 14px;
overflow-wrap: anywhere;
}
.request.added {
border-left: 4px solid var(--added);
}
.request.removed {
border-left: 4px solid var(--removed);
}
.request header {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.badge, .method, .type, .status {
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
}
.added .badge {
background: var(--added-bg);
color: var(--added);
}
.removed .badge {
background: var(--removed-bg);
color: var(--removed);
}
.method, .type, .status {
background: color-mix(in srgb, var(--muted) 14%, transparent);
color: var(--fg);
}
.url {
display: block;
margin: 8px 0 12px;
color: inherit;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px 16px;
margin: 0 0 12px;
}
dl div {
min-width: 0;
}
dt {
color: var(--muted);
font-size: 12px;
}
dd {
margin: 0;
}
details {
margin-top: 8px;
}
summary {
cursor: pointer;
color: var(--muted);
}
pre {
white-space: pre-wrap;
overflow-x: auto;
background: color-mix(in srgb, var(--muted) 10%, transparent);
border-radius: 6px;
padding: 10px;
}
.empty {
color: var(--muted);
}
</style>
</head>
<body>
<main>
<h1>Frontend Browser Network Request Diff</h1>
<p class="meta">Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.</p>
${renderSummary(base, head, diffs)}
${content}
</main>
</body>
</html>
`;
}
async function main() {
const [baseFile, headFile, outputFile] = process.argv.slice(2);
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
await writeFile(outputFile, renderHtml(base, head));
}
// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用)
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -0,0 +1,378 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import * as util from './utility.mts';
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts';
import type { BrowserDiagnostics, NetworkRequest } from './chrome.mts';
export type BrowserMeasurement = {
label: string;
timestamp: string;
url: string;
scenario: string;
diagnostics: BrowserDiagnostics;
durationMs: number;
network: {
requestCount: number;
webSocketConnectionCount: number;
webSocketSentBytes: number;
webSocketReceivedBytes: number;
finishedRequestCount: number;
failedRequestCount: number;
cachedRequestCount: number;
serviceWorkerRequestCount: number;
totalEncodedBytes: number;
totalDecodedBodyBytes: number;
sameOriginEncodedBytes: number;
thirdPartyEncodedBytes: number;
byResourceType: Record<string, {
requests: number;
encodedBytes: number;
decodedBodyBytes: number;
}>;
largestRequests: {
url: string;
method: string;
resourceType: string;
status?: number;
encodedBytes: number;
decodedBodyBytes: number;
}[];
failedRequests: {
url: string;
method: string;
resourceType: string;
errorText?: string;
status?: number;
}[];
};
performance: {
cdpMetrics: Record<string, number>;
runtimeHeap?: {
usedSize: number;
totalSize: number;
};
tabMemory: {
totalBytes: number;
};
webVitals: {
firstPaintMs?: number;
firstContentfulPaintMs?: number;
domContentLoadedEventEndMs?: number;
loadEventEndMs?: number;
longTaskCount: number;
longTaskDurationMs: number;
maxLongTaskDurationMs: number;
resourceEntryCount: number;
domElements: number;
};
};
heapSnapshot: HeapSnapshotData;
};
export type BrowserMeasurementSample = BrowserMeasurement & {
round: number;
networkRequests?: NetworkRequest[];
};
export type BrowserMetricsReport = {
label: string;
timestamp: string;
url: string;
scenario: string;
sampleCount: number;
aggregation: 'median';
summary: BrowserMeasurement;
samples: BrowserMeasurementSample[];
};
function escapeCell(value: string) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function truncate(value: string, maxLength = 140) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
function formatMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`;
return `${util.formatNumber(value)} ms`;
}
function formatSecondsAsMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
return formatMs(value * 1_000);
}
function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return formatter(0);
return util.formatColoredDelta(delta, v => formatter(v), colorThreshold);
}
function finiteValues(values: (number | null | undefined)[]) {
return values.filter(value => Number.isFinite(value)) as number[];
}
function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) {
const values = finiteValues(report.samples.map(sample => getValue(sample)));
if (values.length < 2) return null;
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) {
const spread = sampleSpread(report, getSampleValue);
if (spread == null) return formatter(value);
return `${formatter(value)}<br>± ${formatter(spread)}`;
}
function metricRow(
label: string,
base: BrowserMetricsReport,
head: BrowserMetricsReport,
getSummaryValue: (summary: BrowserMeasurement) => number,
getSampleValue: (sample: BrowserMeasurementSample) => number,
formatter: (value: number) => string,
significantThreshold = 0,
skipIfNotSignificant = true
) {
const baseValue = getSummaryValue(base.summary);
const headValue = getSummaryValue(head.summary);
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample));
// 有意な閾値に満たない場合はそもそもrowとして出力しない
if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null;
const percent = baseValue === 0 ? null : summary.median * 100 / baseValue;
//const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}<br>${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
const deltaMedian = formatDelta(summary.median, formatter, significantThreshold);
//return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`;
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, significantThreshold)} |`;
}
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
}
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
return resourceTypeBytes(sample, resourceTypes);
}
function getMetric(report: BrowserMeasurement, key: string) {
return report.performance.cdpMetrics[key];
}
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
const rows = [
//metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all),
//metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber),
metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all),
metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all),
metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all),
metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all),
metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all),
metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all),
metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all),
metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all),
metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all),
//metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs),
//metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs),
//metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber),
//metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs),
//metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs),
//metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes),
//metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes),
//metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000),
//metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber),
//metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber),
//metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber),
//metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber),
//metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber),
//metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs),
//metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs),
metricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all),
metricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all),
metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all),
metricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all),
metricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all),
metricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all),
metricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all),
metricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all),
metricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all),
].filter(row => row != null);
return [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
...rows,
].join('\n');
}
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
const keys = [...new Set([
...preferredOrder,
...Object.keys(base.summary.network.byResourceType),
...Object.keys(head.summary.network.byResourceType),
])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null);
const lines = [
'<table>',
'<thead>',
'<tr>',
'<th rowspan="2">Type</th>',
'<th colspan="3">Requests</th>',
'<th colspan="3">Encoded bytes</th>',
'</tr>',
'<tr>',
'<th>Base</th>',
'<th>Head</th>',
'<th>Δ</th>',
'<th>Base</th>',
'<th>Head</th>',
'<th>Δ</th>',
'</tr>',
'</thead>',
'<tbody>',
];
for (const key of keys) {
const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
lines.push('<tr>');
lines.push(`<td><b>${key}</b></td>`);
lines.push(`<td align="right">${util.formatNumber(baseRow.requests)}</td>`);
lines.push(`<td align="right">${util.formatNumber(headRow.requests)}</td>`);
lines.push(`<td align="right">${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}</td>`);
lines.push(`<td align="right">${util.formatBytes(baseRow.encodedBytes)}</td>`);
lines.push(`<td align="right">${util.formatBytes(headRow.encodedBytes)}</td>`);
lines.push(`<td align="right">${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
lines.push('</tr>');
}
lines.push('</tbody>');
lines.push('</table>');
return lines.join('\n');
}
function renderLargestRequests(report: BrowserMetricsReport, title: string) {
if (report.summary.network.largestRequests.length === 0) return null;
const lines = [
`<details><summary>${title}</summary>`,
'',
'| Resource | Type | Status | Encoded | Decoded |',
'| --- | --- | ---: | ---: | ---: |',
];
for (const request of report.summary.network.largestRequests.slice(0, 10)) {
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`);
}
lines.push('', '</details>');
return lines.join('\n');
}
function renderFailedRequests(report: BrowserMetricsReport, title: string) {
if (report.summary.network.failedRequests.length === 0) return null;
const lines = [
`<details><summary>${title}</summary>`,
'',
'| Resource | Type | Status | Error |',
'| --- | --- | ---: | --- |',
];
for (const request of report.summary.network.failedRequests.slice(0, 20)) {
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`);
}
lines.push('', '</details>');
return lines.join('\n');
}
function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport {
return {
summary: report.summary.heapSnapshot,
samples: report.samples.map(sample => ({
round: sample.round,
data: sample.heapSnapshot,
})),
};
}
function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: {
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
detailedHtmlUrl?: string;
}) {
const detailedHtmlUrl = options.detailedHtmlUrl;
const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
const lines = [
'## 🖥 Frontend Browser Diagnostics Report',
'',
renderSummaryTable(base, head),
'',
'<i>Only metrics showing significant changes are displayed.</i>',
'',
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
'<details>',
'<summary>Requests by resource type</summary>',
'',
renderResourceTypeTable(base, head),
'',
'</details>',
'',
'<details>',
'<summary>V8 heap snapshot statistics</summary>',
'',
heapSnapshotTable ?? '_No V8 heap snapshot data._',
'',
heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
'',
`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`,
'</details>',
'',
];
for (const section of [
//renderLargestRequests(head, 'Largest representative head requests'),
//renderFailedRequests(base, 'Failed representative base requests'),
//renderFailedRequests(head, 'Failed representative head requests'),
]) {
if (section == null) continue;
lines.push(section, '');
}
return lines.filter(line => line != null).join('\n').trimEnd() + '\n';
}
async function main() {
const [baseFile, headFile, outputFile] = process.argv.slice(2);
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
await writeFile(outputFile, renderMd(base, head, {
baseHeapSnapshotUrl: process.env.FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL!,
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL!,
detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL,
}));
}
// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用)
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -7,9 +7,9 @@ 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 marker = '<!-- misskey-frontend-bundle-diagnostics -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
const locale = 'ja-JP';
//function sharePercent(value, total) {
// if (total === 0) return '0%';
@@ -40,13 +40,24 @@ function escapeCell(value: string) {
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
const stableNamedChunks = new Set(['vue', 'i18n']);
type FileEntry = {
key: string;
comparisonKey: string | null;
displayName: string;
file: string;
manifestKeys: string[];
size: number;
};
type CollectedReport = {
manifest: Manifest;
chunks: FileEntry[];
comparableChunks: Record<string, FileEntry>;
chunksByManifestKey: Record<string, FileEntry>;
startupFiles: string[];
};
function entryDisplayName(entry: FileEntry) {
if (!entry) return '';
return entry.displayName || entry.file;
@@ -60,23 +71,26 @@ function findEntryKey(manifest: Manifest) {
?? null;
}
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
function stableChunkKey(chunk: Manifest[string]) {
if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`;
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
return null;
}
function collectStartupKeys(manifest: Manifest) {
function collectStartupManifestKeys(manifest: Manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<string>();
if (entryKey == null) return keys;
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
function visit(key: string) {
function visit(key: string, importedBy?: 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);
}
const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`;
if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`);
if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`);
if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`);
keys.add(key);
for (const importKey of chunk.imports ?? []) visit(importKey, key);
}
visit(entryKey);
@@ -102,26 +116,41 @@ async function resolveBuiltFile(outDir: string, file: string) {
};
}
async function collectReport(repoDir: string) {
async function collectReport(repoDir: string): Promise<CollectedReport> {
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>();
const chunksByFile = new Map<string, FileEntry>();
const comparableChunks = new Map<string, FileEntry>();
const chunksByManifestKey = new Map<string, FileEntry>();
for (const [key, chunk] of Object.entries(manifest)) {
for (const [manifestKey, 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 comparisonKey = stableChunkKey(chunk);
let entry = chunksByFile.get(builtFile.relativePath);
if (entry == null) {
entry = {
comparisonKey,
displayName: chunk.src ?? chunk.name ?? manifestKey,
file: builtFile.relativePath,
manifestKeys: [manifestKey],
size: await util.fileSize(builtFile.absolutePath),
};
chunksByFile.set(entry.file, entry);
} else if (entry.comparisonKey !== comparisonKey) {
throw new Error(`Conflicting identities for ${entry.file}`);
} else {
entry.manifestKeys.push(manifestKey);
}
chunksByManifestKey.set(manifestKey, entry);
if (comparisonKey != null) {
const existing = comparableChunks.get(comparisonKey);
if (existing != null && existing.file !== entry.file) {
throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`);
}
comparableChunks.set(comparisonKey, entry);
}
}
const localeDir = path.join(outDir, locale);
@@ -129,21 +158,29 @@ async function collectReport(repoDir: string) {
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,
if (chunksByFile.has(relativePath)) continue;
chunksByFile.set(relativePath, {
comparisonKey: null,
displayName: relativePath,
file: relativePath,
size,
manifestKeys: [],
size: await util.fileSize(fullPath),
});
}
}
const startupFiles = new Set<string>();
for (const manifestKey of collectStartupManifestKeys(manifest)) {
const entry = chunksByManifestKey.get(manifestKey);
if (entry != null) startupFiles.add(entry.file);
}
return {
manifest,
chunks: Object.fromEntries(byKey),
startupKeys: [...collectStartupKeys(manifest)],
chunks: [...chunksByFile.values()],
comparableChunks: Object.fromEntries(comparableChunks),
chunksByManifestKey: Object.fromEntries(chunksByManifestKey),
startupFiles: [...startupFiles],
};
}
@@ -299,29 +336,30 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
`<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>`),
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</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>`),
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</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];
function getChunkComparisonRows(keys: string[], before: Record<string, FileEntry>, after: Record<string, FileEntry>) {
return keys.map(key => {
const beforeEntry = before[key];
const afterEntry = after[key];
const beforeSize = beforeEntry?.size ?? 0;
const afterSize = afterEntry?.size ?? 0;
return {
key,
name: entryDisplayName(beforeEntry ?? afterEntry),
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeFile: beforeEntry?.file,
afterFile: afterEntry?.file,
beforeSize,
afterSize,
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
@@ -330,6 +368,53 @@ function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeo
});
}
type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
type ChunkAggregate = {
beforeSize: number;
afterSize: number;
beforeCount: number;
afterCount: number;
};
const smallDeltaThreshold = 5;
function sumChunkSizes(chunks: FileEntry[]) {
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
}
function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate {
const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null);
const afterGenerated = after.filter(chunk => chunk.comparisonKey == null);
return {
beforeSize: sumChunkSizes(beforeGenerated),
afterSize: sumChunkSizes(afterGenerated),
beforeCount: beforeGenerated.length,
afterCount: afterGenerated.length,
};
}
function hasSmallDelta(row: ChunkComparisonRow) {
return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold;
}
function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
return {
beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0),
beforeCount: rows.filter(row => row.beforeFile != null).length,
afterCount: rows.filter(row => row.afterFile != null).length,
};
}
function comparableMap(chunks: FileEntry[]) {
const entries: [string, FileEntry][] = [];
for (const chunk of chunks) {
if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]);
}
return Object.fromEntries(entries);
}
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
@@ -342,67 +427,89 @@ function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summ
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[number]) {
function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
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_';
function chunkFileDisplay(row: ChunkComparisonRow) {
if (row.beforeFile == null) return row.afterFile ?? '';
if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile;
return `${row.beforeFile}${row.afterFile}`;
}
function chunkMarkdownTable(
rows: ReturnType<typeof getChunkComparisonRows>,
total?: { beforeSize: number; afterSize: number },
generated?: ChunkAggregate,
other?: ChunkAggregate,
) {
const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0);
const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0);
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) 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(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
const chunkFile = chunkFileDisplay(row);
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{(+)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\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{(-)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\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('\\%', '\\\\%')} |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
}
}
if (hasGenerated) {
lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
}
if (hasOther) {
lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).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 beforeComparable = before.comparableChunks;
const afterComparable = after.comparableChunks;
const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])];
const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable);
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),
beforeSize: sumChunkSizes(before.chunks),
afterSize: sumChunkSizes(after.chunks),
};
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const diffGenerated = generatedAggregate(before.chunks, after.chunks);
const diffOther = comparisonRowsAggregate(changedRows.filter(hasSmallDelta));
const diffRows = changedRows.filter(row => !hasSmallDelta(row)).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 beforeStartupFiles = new Set(before.startupFiles);
const afterStartupFiles = new Set(after.startupFiles);
const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file));
const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file));
const beforeStartupComparable = comparableMap(beforeStartupChunks);
const afterStartupComparable = comparableMap(afterStartupChunks);
const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])];
const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
beforeSize: sumChunkSizes(beforeStartupChunks),
afterSize: sumChunkSizes(afterStartupChunks),
};
const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks);
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
@@ -412,14 +519,14 @@ function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectRepo
'<details open>',
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal),
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal),
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
@@ -476,67 +583,6 @@ function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerR
return lines.join('\n');
}
const visualizerTreemapLimit = 50;
function mermaidTreemapLabel(value: string) {
const label = String(value)
.replaceAll('\\', '/')
.replaceAll('"', "'")
.replaceAll('`', "'")
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.trim();
return label === '' ? '(unknown)' : label;
}
function mermaidTreemapModuleLabel(id: string) {
const normalizedId = String(id).replaceAll('\\', '/');
const filePath = normalizedId.split(/[?#]/, 1)[0];
const fileName = path.posix.basename(filePath);
return mermaidTreemapLabel(fileName || normalizedId);
}
function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
const rows = report.hotModules
.filter((row) => row.renderedLength > 0)
.slice(0, visualizerTreemapLimit);
const topRendered = rows.reduce((sum, row) => sum + row.renderedLength, 0);
const otherRendered = Math.max(0, report.metrics.renderedLength - topRendered);
const lines = [
'```mermaid',
`%%{init: ${JSON.stringify({
treemap: {
diagramPadding: 0,
padding: 0,
nodeHeight: 70,
},
})}}%%`,
'treemap-beta',
`"${mermaidTreemapLabel(label)}"`,
];
for (const row of rows) {
lines.push(` "${mermaidTreemapModuleLabel(row.id)}": ${Math.round(row.renderedLength)}`);
}
if (otherRendered > 0) {
lines.push(` "Other": ${Math.round(otherRendered)}`);
}
lines.push('```');
return lines.join('\n');
}
function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
return [
`<details${open ? ' open' : ''}>`,
`<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`,
'',
renderVisualizerTreemap(label, report),
'',
'</details>',
].join('\n');
}
const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir);
@@ -545,7 +591,7 @@ const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as Vi
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
const afterVisualizerReport = collectVisualizerReport(afterStats);
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
const body = [
marker,
@@ -558,10 +604,6 @@ const body = [
'',
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
'',
renderVisualizerTreemapDetails('Before', beforeVisualizerReport),
'',
renderVisualizerTreemapDetails('After', afterVisualizerReport),
'',
visualizerArtifactLink,
].join('\n');

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

@@ -0,0 +1,520 @@
/*
* 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;
}[];
};
export const defaultHeapSnapshotBreakdownTopN = 6;
export function createEmptyHeapSnapshotData(): HeapSnapshotData {
const categories = {} as HeapSnapshotData['categories'];
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
categories[category] = 0;
nodeCounts[category] = 0;
}
return {
categories,
nodeCounts,
breakdowns: {} as HeapSnapshotData['breakdowns'],
};
}
function sanitizeHeapSnapshotBreakdownLabel(value: unknown, 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: string, name: string) {
if (category === 'strings') return type;
if (category === 'jsArrays') {
if (type === 'array elements') return 'Array elements';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'typedArrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
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 === 'extra native bytes') return 'Extra native bytes';
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);
}
export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, topN);
const otherValue = entries
.slice(topN)
.reduce((sum, [, value]) => sum + value, 0);
const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
export function collapseHeapSnapshotBreakdowns(
breakdowns: Partial<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
topN = defaultHeapSnapshotBreakdownTopN,
) {
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category === 'total') continue;
const categoryBreakdown = breakdowns[category];
if (categoryBreakdown == null) continue;
const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
if (Object.keys(collapsedCategory).length > 0) {
collapsed[category] = collapsedCategory;
}
}
return collapsed;
}
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const edges = snapshot?.edges;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !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 edgeFields = meta.edge_fields;
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
const edgeCountOffset = nodeFields.indexOf('edge_count');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const edgeTypeOffset = edgeFields.indexOf('type');
const edgeNameOffset = edgeFields.indexOf('name_or_index');
const edgeToNodeOffset = edgeFields.indexOf('to_node');
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
throw new Error('Heap snapshot is missing required edge fields');
}
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
const nodeFieldCount = nodeFields.length;
const edgeFieldCount = edgeFields.length;
const nativeType = nodeTypeNames.indexOf('native');
const codeType = nodeTypeNames.indexOf('code');
const hiddenType = nodeTypeNames.indexOf('hidden');
const stringTypes = new Set([
nodeTypeNames.indexOf('string'),
nodeTypeNames.indexOf('concatenated string'),
nodeTypeNames.indexOf('sliced string'),
]);
const internalEdgeType = edgeTypeNames.indexOf('internal');
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
const breakdowns = {} as Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category !== 'total') breakdowns[category] = {};
}
function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}
const edgeStartIndexes = new Map<number, number>();
const retainerCounts = new Map<number, number>();
let edgeIndex = 0;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
edgeStartIndexes.set(nodeIndex, edgeIndex);
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
}
}
const jsArrayElementNodeIndexes = new Set<number>();
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
if (value <= 0) return;
categories[category] += value;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
if (nodeIndex != null) nodeCounts[category]++;
}
function addJsArrayElementSize(nodeIndex: number) {
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
if (edgeType !== internalEdgeType) continue;
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
if (edgeName !== 'elements') continue;
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
jsArrayElementNodeIndexes.add(elementsNodeIndex);
}
break;
}
}
if (extraNativeBytes > 0) {
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
}
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
const typeId = nodes[nodeIndex + typeOffset];
const type = nodeTypeNames[typeId] ?? 'unknown';
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
categories.total += selfSize;
nodeCounts.total++;
if (typeId === hiddenType) {
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
continue;
}
if (typeId === nativeType) {
if (name === 'system / JSArrayBufferData') {
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
} else {
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
}
continue;
}
if (typeId === codeType) {
addCategoryValue('code', selfSize, type, name, nodeIndex);
continue;
}
if (stringTypes.has(typeId)) {
addCategoryValue('strings', selfSize, type, name, nodeIndex);
continue;
}
if (name === 'Array') {
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
addJsArrayElementSize(nodeIndex);
continue;
}
}
categories.total += extraNativeBytes;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
const typeId = nodes[nodeIndex + typeOffset];
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
if (name === 'Array') continue;
const type = nodeTypeNames[typeId] ?? 'unknown';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
}
return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
};
}
function finiteMedian(values: (number | null | undefined)[]) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return null;
return util.median(finiteValues);
}
export function summarizeHeapSnapshotDataSamples<T>(
samples: T[],
getData: (sample: T) => HeapSnapshotData | null | undefined,
options: { breakdownTopN?: number } = {},
) {
const data = samples.map(getData);
const categories = {} as HeapSnapshotData['categories'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
if (value != null) categories[category] = value;
}
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
if (value != null) nodeCounts[category] = value;
}
if (Object.keys(categories).length === 0) return null;
const breakdowns = {} as NonNullable<HeapSnapshotData['breakdowns']>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category === 'total') continue;
const childKeys = new Set<string>();
for (const snapshot of data) {
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
if (value != null) categoryBreakdown[childKey] = value;
}
const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
if (Object.keys(collapsed).length > 0) {
breakdowns[category] = collapsed;
}
}
return {
categories,
nodeCounts,
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
};
}
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, 100000)}<br>${util.formatDeltaPercent(percent, 0.1).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, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
lines.push('| | | | | | | |');
} else {
const deltaMedian = util.formatDeltaBytes(summary.median, 100000);
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, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
}
}
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

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

View File

@@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setTimeout } from 'node:timers/promises';
type MemoryStabilityTimer = {
now: () => number;
wait: (durationMs: number) => Promise<void>;
};
const intervalMs = 2000;
const maxWaitMs = 10000;
const windowSize = 3;
const slopeThresholdKiBPerSecond = 256;
const stabilityMetrics = ['Pss', 'Private_Dirty'] as const;
const defaultTimer: MemoryStabilityTimer = {
now: () => performance.now(),
wait: durationMs => setTimeout(durationMs),
};
function getMaxAbsoluteSlopes<T extends Record<string, number>>(readings: { elapsedMs: number; memoryUsage: T }[]) {
const result = {} as Record<typeof stabilityMetrics[number], number>;
for (const metric of stabilityMetrics) {
let maxAbsoluteSlope = 0;
for (let i = 1; i < readings.length; i++) {
const previous = readings[i - 1];
const current = readings[i];
const durationSeconds = (current.elapsedMs - previous.elapsedMs) / 1000;
maxAbsoluteSlope = Math.max(maxAbsoluteSlope, Math.abs(current.memoryUsage[metric] - previous.memoryUsage[metric]) / durationSeconds);
}
result[metric] = maxAbsoluteSlope;
}
return result;
}
export async function measureMemoryUntilStable<T extends Record<string, number>>(
readMemoryUsage: () => Promise<T>,
timer: MemoryStabilityTimer = defaultTimer,
) {
const startedAt = timer.now();
const readings: { elapsedMs: number; memoryUsage: T }[] = [];
let maxAbsoluteSlopesKiBPerSecond: Record<typeof stabilityMetrics[number], number> | null = null;
while (true) {
const memoryUsage = await readMemoryUsage();
const elapsedMs = timer.now() - startedAt;
readings.push({ elapsedMs, memoryUsage });
let converged = false;
if (readings.length >= windowSize) {
const latestSlopes = getMaxAbsoluteSlopes(readings.slice(-windowSize));
maxAbsoluteSlopesKiBPerSecond = latestSlopes;
converged = stabilityMetrics.every(metric => latestSlopes[metric] <= slopeThresholdKiBPerSecond);
}
if (converged || elapsedMs >= maxWaitMs) {
return {
memoryUsage,
stability: {
converged,
readingCount: readings.length,
elapsedMs,
maxAbsoluteSlopesKiBPerSecond,
},
};
}
await timer.wait(Math.min(intervalMs, maxWaitMs - elapsedMs));
}
}

View File

@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { measureMemoryUntilStable } from './memory-stability-util.mts';
function createTimer() {
let elapsedMs = 0;
return {
now: () => elapsedMs,
wait: async (durationMs: number) => {
elapsedMs += durationMs;
},
};
}
async function measure(readings: Record<string, number>[]) {
let readCount = 0;
const result = await measureMemoryUntilStable(
async () => readings[readCount++],
createTimer(),
);
return { result, readCount };
}
test('adopts the latest reading once Pss and Private_Dirty slopes converge', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500, HeapUsed: 300 },
{ Pss: 1100, Private_Dirty: 520, HeapUsed: 310 },
{ Pss: 1200, Private_Dirty: 540, HeapUsed: 320 },
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 3);
assert.deepEqual(result.memoryUsage, readings[2]);
assert.deepEqual(result.stability, {
converged: true,
readingCount: 3,
elapsedMs: 4000,
maxAbsoluteSlopesKiBPerSecond: {
Pss: 50,
Private_Dirty: 10,
},
});
});
test('uses only the latest readings when determining convergence', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 2000, Private_Dirty: 1000 },
{ Pss: 3000, Private_Dirty: 1500 },
{ Pss: 3040, Private_Dirty: 1520 },
{ Pss: 3080, Private_Dirty: 1540 },
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 5);
assert.equal(result.stability.converged, true);
assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, {
Pss: 20,
Private_Dirty: 10,
});
});
test('bounds the wait and reports the latest slopes when memory does not converge', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 520 },
{ Pss: 2200, Private_Dirty: 540 },
{ Pss: 2800, Private_Dirty: 560 },
{ Pss: 3400, Private_Dirty: 580 },
{ Pss: 4000, Private_Dirty: 600 },
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 6);
assert.deepEqual(result.memoryUsage, readings[5]);
assert.deepEqual(result.stability, {
converged: false,
readingCount: 6,
elapsedMs: 10000,
maxAbsoluteSlopesKiBPerSecond: {
Pss: 300,
Private_Dirty: 10,
},
});
});
test('does not treat opposing adjacent slopes as convergence', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 500 },
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 500 },
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 500 },
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 6);
assert.equal(result.stability.converged, false);
assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, {
Pss: 300,
Private_Dirty: 0,
});
});

View File

@@ -3,20 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn } from 'node:child_process';
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
export const heapSnapshotCategories = [
'Total',
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
] as const;
export function sleep(ms: number) {
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
}
export function median(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
@@ -26,12 +21,46 @@ export function median(values: number[]) {
}
export function mad(values: number[]) {
if (values.length < 2) return null;
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('/');
}
@@ -69,11 +98,12 @@ export function escapeLatex(text: string) {
.replaceAll('%', '\\%');
}
export function formatColoredDelta(text: string, delta: number) {
if (delta === 0) return text;
const color = delta > 0 ? 'orange' : 'green';
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return text(0);
const sign = delta > 0 ? '+' : '-';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
const color = delta > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
}
const numberFormatter = new Intl.NumberFormat('en-US', {
@@ -86,11 +116,11 @@ export function formatNumber(value: number) {
export function formatBytes(value: number) {
if (value === 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000;
unitIndex += 1;
}
@@ -98,35 +128,34 @@ export function formatBytes(value: number) {
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
export function calcAndFormatDeltaNumber(before: number, after: number) {
export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
return formatColoredDelta(delta, v => formatNumber(v), colorThreshold);
}
export function formatDeltaBytes(deltaBytes: number) {
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold);
}
export function calcAndFormatDeltaBytes(before: number, after: number) {
export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatDeltaBytes(delta);
return formatDeltaBytes(delta, colorThreshold);
}
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 formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold);
}
export function calcAndFormatDeltaPercent(before: number, after: number) {
export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const delta = after - before;
return formatDeltaPercent(delta / before * 100);
return formatDeltaPercent(delta / before * 100, colorThreshold);
}
export function commandName(command: string) {
@@ -177,3 +206,91 @@ export function run(command: string, args: string[], options: { cwd?: string; en
});
});
}
export function startServer(label: string, repoDir: string) {
process.stderr.write(`[${label}] Starting Misskey test server\n`);
const child = spawn(commandName('pnpm'), ['start:test'], {
cwd: repoDir,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
});
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
return child;
}
export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) {
const startedAt = Date.now();
while (Date.now() - startedAt < 120_000) {
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
try {
const response = await fetch(`${baseUrl}/`, { redirect: 'manual' });
if (response.status < 500) return;
} catch {
// retry
}
await sleep(1_000);
}
throw new Error(`Timed out waiting for ${baseUrl}`);
}
export async function api(baseUrl: string, endpoint: string, body: Record<string, unknown>) {
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`);
}
if (response.status === 204) return null;
return await response.json();
}
export async function prepareInstance(baseUrl: string) {
await api(baseUrl, 'reset-db', {});
await api(baseUrl, 'admin/accounts/create', {
username: 'admin',
password: 'admin1234',
setupPassword: 'example_password_please_change_this_or_you_will_get_hacked',
});
}
export async function stopServer(child: ChildProcessWithoutNullStreams) {
if (child.exitCode != null) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else if (child.pid != null) {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
await new Promise<void>(resolvePromise => {
if (child.exitCode != null) {
resolvePromise();
return;
}
child.once('exit', () => resolvePromise());
setTimeout(() => {
if (child.pid != null) {
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else {
process.kill(-child.pid, 'SIGKILL');
}
} catch {
child.kill('SIGKILL');
}
}
resolvePromise();
}, 10_000).unref();
});
}

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0

View File

@@ -0,0 +1,76 @@
name: Backend diagnostics (comment)
on:
workflow_run:
types: [completed]
workflows:
- Backend diagnostics (inspect) # backend-diagnostics.inspect.yml
jobs:
compare-memory:
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.3
- name: Download artifacts
uses: actions/download-artifact@v8
with:
pattern: memory-artifact-*
path: artifacts
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Check artifacts
run: ls -lh artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Find heap snapshot artifacts
id: find-heap-snapshot-artifacts
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;
const run_id = context.payload.workflow_run.id;
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id,
});
const artifactNames = {
base: 'base-heap-snapshot.heapsnapshot',
head: 'head-heap-snapshot.heapsnapshot',
};
for (const [label, artifactName] of Object.entries(artifactNames)) {
const artifact = artifacts.find(artifact => artifact.name === artifactName);
if (artifact == null) throw new Error(`Artifact not found: ${artifactName}`);
core.setOutput(`${label}-url`, `https://github.com/${owner}/${repo}/actions/runs/${run_id}/artifacts/${artifact.id}`);
}
- id: build-comment
name: Build memory comment
env:
MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE: ${{ steps.find-heap-snapshot-artifacts.outputs.base-url }}
MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifacts.outputs.head-url }}
run: node .github/scripts/backend-diagnostics.render-md.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_memory_diff
file-path: ./output.md
- name: Tell error to PR
uses: thollander/actions-comment-pull-request@v3
if: failure() && steps.load-pr-num.outputs.pr-number
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_memory_diff_error
message: |
An error occurred while comparing backend memory usage. See [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.

View File

@@ -1,5 +1,5 @@
# this name is used in report-backend-memory.yml so be careful when change name
name: Get backend memory usage
# this name is used in backend-diagnostics.comment.yml so be careful when change name
name: Backend diagnostics (inspect)
on:
pull_request:
@@ -10,13 +10,11 @@ on:
- 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
- .github/scripts/backend-diagnostics.render-md.mts
- .github/scripts/backend-diagnostics.inspect.mts
- .github/scripts/memory-stability-util*.mts
- .github/workflows/backend-diagnostics.inspect.yml
- .github/workflows/backend-diagnostics.comment.yml
jobs:
get-memory-usage:
@@ -39,19 +37,19 @@ jobs:
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: head/package.json
- name: Use Node.js
@@ -92,14 +90,24 @@ jobs:
run: pnpm build
- name: Measure backend memory usage
env:
MK_MEMORY_COMPARE_ROUNDS: 5
MK_MEMORY_COMPARE_ROUNDS: 10
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
run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json
- name: Upload base heap snapshot
uses: actions/upload-artifact@v7
with:
path: base-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
retention-days: 7
- name: Upload head heap snapshot
uses: actions/upload-artifact@v7
with:
path: head-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
retention-days: 7
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
@@ -107,8 +115,6 @@ jobs:
path: |
memory-base.json
memory-head.json
js-footprint-base.json
js-footprint-head.json
save-pr-number:
runs-on: ubuntu-latest

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout head
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -18,7 +18,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
submodules: true
persist-credentials: false
@@ -66,7 +66,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
with:
submodules: true
persist-credentials: false

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Check version
run: |
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Check
run: |
counter=0
@@ -44,7 +44,6 @@ jobs:
}
directories=(
"cypress/e2e"
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"

View File

@@ -10,7 +10,7 @@ jobs:
check_copyright_year:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
- run: |
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
echo "Please change copyright year!"

View File

@@ -27,9 +27,6 @@ jobs:
pr-ref: ${{ steps.get-ref.outputs.pr-ref }}
wait_time: ${{ steps.get-wait-time.outputs.wait_time }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Check allowed users
id: check-allowed-users
env:

View File

@@ -27,7 +27,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub

View File

@@ -32,7 +32,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta

View File

@@ -17,7 +17,7 @@ jobs:
DOCKLE_VERSION: 0.4.15
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
run: |

View File

@@ -0,0 +1,44 @@
name: Frontend browser diagnostics (comment)
on:
workflow_run:
workflows:
- Frontend browser diagnostics (inspect)
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend browser diagnostics report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download browser metrics report
uses: actions/download-artifact@v8
with:
name: frontend-browser-diagnostics
path: ${{ runner.temp }}/frontend-browser-diagnostics
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Load PR number
id: load-pr-number
shell: bash
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT"
- name: Comment on pull request
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
comment-tag: frontend-browser-diagnostics
file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md

View File

@@ -0,0 +1,210 @@
name: Frontend browser diagnostics (inspect)
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/backend/**
- 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/misskey/test.yml
- .github/scripts/utility.mts
- .github/scripts/heap-snapshot-util.mts
- .github/scripts/chrome.mts
- .github/scripts/frontend-browser-diagnostics.render-additional-html.mts
- .github/scripts/frontend-browser-diagnostics.render-md.mts
- .github/scripts/frontend-browser-diagnostics.inspect.mts
- .github/workflows/frontend-browser-diagnostics.inspect.yml
- .github/workflows/frontend-browser-diagnostics.comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Measure frontend browser metrics
runs-on: ubuntu-latest
timeout-minutes: 90
services:
postgres:
image: postgres:18
ports:
- 54312:5432
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:8
ports:
- 56312:6379
steps:
- name: Checkout base
uses: actions/checkout@v6.0.3
with:
persist-credentials: false
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.3
with:
persist-credentials: false
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: after/package.json
- name: Setup Node.js
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
working-directory: before
run: pnpm i --frozen-lockfile
- name: Configure base
working-directory: before
run: cp .github/misskey/test.yml .config
- name: Build base
working-directory: before
run: pnpm build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Configure pull request
working-directory: after
run: cp .github/misskey/test.yml .config
- name: Build pull request
working-directory: after
run: pnpm build
- name: Install Playwright browsers
working-directory: after/packages/frontend
run: pnpm exec playwright install --with-deps --no-shell chromium
- name: Measure frontend browser metrics
shell: bash
env:
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
mkdir -p "$REPORT_DIR"
node after/.github/scripts/frontend-browser-diagnostics.inspect.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json"
- name: Upload browser base heap snapshot
id: upload-browser-base-heap-snapshot
uses: actions/upload-artifact@v7
with:
path: base-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
retention-days: 7
- name: Upload browser head heap snapshot
id: upload-browser-head-heap-snapshot
uses: actions/upload-artifact@v7
with:
path: head-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
retention-days: 7
- name: Generate browser detailed html
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
node after/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html"
- name: Upload browser detailed html
id: upload-browser-detailed-html
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-detailed-html
path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate browser metrics report
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_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }}
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
node after/.github/scripts/frontend-browser-diagnostics.render-md.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-diagnostics.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 browser metrics report
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
test -s "$REPORT_DIR/frontend-browser-diagnostics.md"
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
test -s "$REPORT_DIR/pr-number.txt"
test -s "$REPORT_DIR/head-sha.txt"
cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload browser metrics report
uses: actions/upload-artifact@v7
with:
name: frontend-browser-diagnostics
path: |
${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json
${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json
${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md
${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt
${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt
${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt
${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt
if-no-files-found: error
retention-days: 7

View File

@@ -0,0 +1,44 @@
name: Frontend bundle diagnostics (comment)
on:
workflow_run:
workflows:
- Frontend bundle diagnostics (inspect)
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend bundle report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download bundle report
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: Load PR number
id: load-pr-number
shell: bash
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
- name: Comment on pull request
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
comment-tag: frontend-bundle-diagnostics
file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md

View File

@@ -1,4 +1,4 @@
name: frontend-bundle-report
name: Frontend bundle diagnostics (inspect)
on:
pull_request:
@@ -21,27 +21,25 @@ on:
- 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
- .github/scripts/frontend-bundle-diagnostics.render-md.mts
- .github/workflows/frontend-bundle-diagnostics.inspect.yml
- .github/workflows/frontend-bundle-diagnostics.comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-bundle-report-${{ github.event.pull_request.number }}
group: frontend-bundle-diagnostics-inspect-${{ 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
uses: actions/checkout@v6.0.3
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
@@ -49,32 +47,19 @@ jobs:
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
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
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: after/package.json
- name: Setup Node.js
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
@@ -84,21 +69,17 @@ jobs:
after/pnpm-lock.yaml
- name: Install dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare report output
if: steps.check-base-visualizer.outputs.supported == 'true'
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
- name: Build frontend report for base
if: steps.check-base-visualizer.outputs.supported == 'true'
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
@@ -106,17 +87,14 @@ jobs:
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'
@@ -125,7 +103,6 @@ jobs:
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:
@@ -136,7 +113,6 @@ jobs:
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 }}
@@ -145,23 +121,21 @@ jobs:
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
node after/.github/scripts/frontend-bundle-diagnostics.render-md.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-diagnostics-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"
test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
if: steps.check-base-visualizer.outputs.supported == 'true'
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-report

View File

@@ -1,318 +0,0 @@
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

@@ -25,12 +25,12 @@ jobs:
ref: refs/pull/${{ github.event.number }}/merge
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -17,7 +17,9 @@ on:
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
pull_request:
paths:
- packages/backend/**
@@ -31,17 +33,19 @@ on:
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
- package.json
jobs:
pnpm_install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
@@ -69,12 +73,12 @@ jobs:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
@@ -100,12 +104,12 @@ jobs:
- sw
- misskey-js
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
@@ -113,3 +117,22 @@ jobs:
- run: pnpm i --frozen-lockfile
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
check-dts:
needs: [pnpm_install]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- run: node --test scripts/check-dts.test.mjs
- run: pnpm check-dts

View File

@@ -1,4 +1,4 @@
name: Lint
name: Locale
on:
push:
@@ -16,12 +16,12 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
with:
node-version-file: ".node-version"

View File

@@ -16,11 +16,11 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -11,44 +11,26 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
pull-requests: write
# api-artifact
steps:
- name: Download artifact
uses: actions/github-script@v9
- name: Download artifacts
uses: actions/download-artifact@v8
with:
script: |
const fs = require('fs');
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("api-artifact-") || artifact.name == "api-artifact"
});
await Promise.all(matchArtifacts.map(async (artifact) => {
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data));
}));
- name: Extract all artifacts
run: |
find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';'
ls -la
pattern: api-artifact-*
path: artifacts
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Check artifacts
run: ls -lh artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Output base
run: cat ./artifacts/api-base.json
- name: Output head
run: cat ./artifacts/api-head.json
- name: Arrange json files
run: |
jq '.' ./artifacts/api-base.json > ./api-base.json
@@ -57,8 +39,6 @@ jobs:
run: diff -u --label=base --label=head ./api-base.json ./api-head.json | cat > api.json.diff
- name: Get full diff
run: diff --label=base --label=head --new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L' ./api-base.json ./api-head.json | cat > api-full.json.diff
- name: Echo full diff
run: cat ./api-full.json.diff
- name: Upload full diff to Artifact
uses: actions/upload-artifact@v7
with:

View File

@@ -1,75 +0,0 @@
name: Report backend memory
on:
workflow_run:
types: [completed]
workflows:
- Get backend memory usage # get-backend-memory.yml
jobs:
compare-memory:
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:
script: |
const fs = require('fs');
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("memory-artifact-") || artifact.name == "memory-artifact"
});
await Promise.all(matchArtifacts.map(async (artifact) => {
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data));
}));
- name: Extract all artifacts
run: |
find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';'
ls -la artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Output base
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Output base JS footprint
run: cat ./artifacts/js-footprint-base.json
- name: Output head JS footprint
run: cat ./artifacts/js-footprint-head.json
- id: build-comment
name: Build memory comment
run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_memory_diff
file-path: ./output.md
- name: Tell error to PR
uses: thollander/actions-comment-pull-request@v3
if: failure() && steps.load-pr-num.outputs.pr-number
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_memory_diff_error
message: |
An error occurred while comparing backend memory usage. See [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.

View File

@@ -22,12 +22,12 @@ jobs:
NODE_OPTIONS: "--max_old_space_size=7168"
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
if: github.event_name != 'pull_request_target'
with:
fetch-depth: 0
submodules: true
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
if: github.event_name == 'pull_request_target'
with:
fetch-depth: 0
@@ -37,7 +37,7 @@ jobs:
if: github.event_name == 'pull_request_target'
run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)"
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -43,7 +43,7 @@ jobs:
ports:
- 56312:6379
meilisearch:
image: getmeili/meilisearch:v1.42.1
image: getmeili/meilisearch:v1.48.1
ports:
- 57712:7700
env:
@@ -51,11 +51,11 @@ jobs:
MEILI_ENV: development
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Install FFmpeg
run: |
sudo apt install -y ffmpeg
@@ -103,11 +103,11 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
@@ -147,11 +147,11 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Get current date
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT

View File

@@ -30,7 +30,7 @@ jobs:
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Install FFmpeg
run: |
sudo apt install -y ffmpeg

View File

@@ -28,11 +28,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
@@ -57,11 +57,6 @@ jobs:
name: E2E tests (frontend)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome]
services:
postgres:
image: postgres:18
@@ -76,17 +71,11 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
# https://github.com/cypress-io/cypress-docker-images/issues/150
#- name: Install mplayer for FireFox
# run: sudo apt install mplayer -y
# if: ${{ matrix.browser == 'firefox' }}
#- uses: browser-actions/setup-firefox@latest
# if: ${{ matrix.browser == 'firefox' }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
@@ -97,29 +86,14 @@ jobs:
run: cp .github/misskey/test.yml .config
- name: Build
run: pnpm build
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
- name: ALSA Env
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
# XXX: This tries reinstalling Cypress if the binary is not cached
# Remove this when the cache issue is fixed
- name: Cypress install
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@v7.1.9
- name: Playwright install
working-directory: packages/frontend
run: pnpm exec playwright install --with-deps chromium
- name: Test
run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e"
timeout-minutes: 15
with:
install: false
start: pnpm start:test
wait-on: 'http://localhost:61812'
headed: true
browser: ${{ matrix.browser }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: ${{ matrix.browser }}-cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v7
if: always()
with:
name: ${{ matrix.browser }}-cypress-videos
path: cypress/videos
name: playwright-e2e-artifacts
path: packages/frontend/test/e2e/artifacts

View File

@@ -22,10 +22,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6.0.3
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0

View File

@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

View File

@@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6.0.3
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:

7
.gitignore vendored
View File

@@ -23,9 +23,8 @@ packages/sw/.yarn/cache
# pnpm
.pnpm-store
# Cypress
cypress/screenshots
cypress/videos
# Playwright
packages/frontend/test/e2e/artifacts
# Coverage
coverage
@@ -35,7 +34,7 @@ coverage
!/.config/example.yml
!/.config/docker_example.yml
!/.config/docker_example.env
!/.config/cypress-devcontainer.yml
!/.config/playwright-devcontainer.yml
docker-compose.yml
./compose.yml
.devcontainer/compose.yml

View File

@@ -1 +1 @@
22.18.0
26.4.0

View File

@@ -1,14 +1,64 @@
## Unreleased
## 2026.7.0
### Note
**今回のリリースではMisskeyの各種動作要件が変更されます。必ずアップグレード前にお使いの環境をご確認ください。**
- センシティブメディアの判定 (NSFW検出) が、本体に内蔵された nsfwjs による推論から、外部サービス [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) への HTTP 呼び出し方式に変更されました。
- これに伴い、本体から `nsfwjs` / `@tensorflow/tfjs` / `@tensorflow/tfjs-node` および同梱の NSFW 判定モデルが削除され、インストール要件 (ネイティブ ML スタック) が緩和されました。
- **センシティブ判定機能を利用しているサーバーは対応が必要です。** 別途 [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) サービスを立ち上げ、コントロールパネルの「モデレーション > センシティブなメディアの検出」で接続先 URL を設定してください。接続先が未設定の場合、センシティブ判定は行われません (すべて非センシティブ扱い)。
- 画像の正規化・動画フレームの抽出・しきい値判定・集約は引き続き本体側で行われ、外部サービスには正規化済み画像の推論のみを委譲します。
- Node.js v24, v26 をサポートしました。**Node.js v22 でも動作しますが、今後のリリースで v22 のサポートを終了する予定**ですので、Node.js のアップデートをご検討ください。
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
- Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
### General
-
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
### Client
-
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
- Enhance: マウスホイールで拡大縮小できるように
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
### Server
-
- Feat: OpenTelemetryサポート
- 詳細な設定はconfigファイルを参照してください。
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
- PostgreSQL query
- Redis command
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
- Enhance: URLプレビューの結果を内部でキャッシュするように
- Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように従来形式の表示は維持
- Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように
- Enhance: バックエンドのログを1行JSON形式で出力できるように
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
## 2026.6.0

View File

@@ -600,6 +600,90 @@ TypeScriptでjsonをimportすると、tscでコンパイルするときにその
コンポーネント自身がmarginを設定するのは問題の元となることはよく知られている
marginはそのコンポーネントを使う側が設定する
### 命名規則
本来それが略称であっても、通常それでひとつのワードとして用いられるものは、略称として扱わない。
#### 例: IP address
Good: `ipAddress` / `IpAddress`
Bad: `IPAddress`
#### 例: User ID
Good: `userId` / `UserId`
Bad: `userID` / `UserID`
#### 例: XMLなHTTPのRequest
Good: `xmlHttpRequest` / `XmlHttpRequest`
Bad: `XMLHttpRequest` / `XMLHTTPRequest`
### 関数化の基準
汎用性が低く(例えばそれを関数化したとしてもその呼び出しが元の場所一か所しか存在しない)、内容も短い処理(例えば10行以下)は、かえって読みにくくなるため、関数化しない。
また、関数化する場合でも、呼び出しがある特定のスコープに限られる場合は、そのスコープ内に閉じ込めた方が分かりやすく簡潔になる場合がある(ただし本来その処理に不要であっても、構造上親のスコープにある関係のない変数や引数にもアクセスできるようになるため、必ずしもそうすれば設計上綺麗になるというわけでもない。状況に応じて判断すべし)。
Bad:
``` ts
function withBrankets(x) {
return `(${x})`;
}
function formatPercent(x) {
return `${x}%`;
}
function formatValue(x) {
return withBrankets(formatPercent(x));
}
function showData(a, b) {
console.log(formatValue(a));
console.log(formatValue(b));
}
```
Good:
``` ts
function formatValue(x) {
return `(${x}%)`;
}
function showData(a, b) {
console.log(formatValue(a));
console.log(formatValue(b));
}
```
or
``` ts
function showData(a, b) {
function formatValue(x) {
return `(${x}%)`;
}
console.log(formatValue(a));
console.log(formatValue(b));
}
```
or
``` ts
function showData(a, b) {
console.log(`(${a}%)`);
console.log(`(${b}%)`);
}
```
## その他
### HTMLのクラス名で follow という単語は使わない
広告ブロッカーで誤ってブロックされる

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.23
ARG NODE_VERSION=22.22.2-bookworm
ARG NODE_VERSION=26.4.0-trixie
# build assets & compile TypeScript

View File

@@ -224,9 +224,8 @@ id: "aidx"
# Media Proxy
#mediaProxy: https://example.com/proxy
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]
allowedPrivateNetworks:
- '127.0.0.1/32'
# Upload or download file size limits (bytes)
#maxFileSize: 262144000

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:61812',
},
})

View File

@@ -1,265 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Before setup instance', () => {
beforeEach(() => {
cy.resetState();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('setup instance', () => {
cy.visitHome();
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
cy.get('[data-cy-admin-username] input').type('admin');
cy.get('[data-cy-admin-password] input').type('admin1234');
cy.get('[data-cy-admin-ok]').click();
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
});
});
describe('After setup instance', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signup', () => {
cy.visitHome();
cy.intercept('POST', '/api/signup').as('signup');
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
cy.get('[data-cy-signup-submit]').click();
cy.wait('@signup');
});
it('signup with duplicated username', () => {
cy.registerUser('alice', 'alice1234');
cy.visitHome();
// ユーザー名が重複している場合の挙動確認
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
});
});
describe('After user signup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signin', () => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-username] input').type('alice{enter}');
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin');
});
it('suspend', function() {
cy.request('POST', '/api/admin/suspend-user', {
i: this.admin.token,
userId: this.alice.id,
});
cy.visitHome();
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type('alice{enter}');
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
});
});
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).should('be.visible');
});
it('account setup wizard', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).click();
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
// TODO: アイコン設定テスト
cy.get('[data-cy-user-setup-continue]').click();
// プライバシー設定
cy.get('[data-cy-user-setup-continue]').click();
// フォローはスキップ
cy.get('[data-cy-user-setup-continue]').click();
// プッシュ通知設定はスキップ
cy.get('[data-cy-user-setup-continue]').click();
cy.get('[data-cy-user-setup-continue]').click();
});
});
describe('After user setup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('note', () => {
cy.get('[data-cy-open-post-form]').should('be.visible');
cy.get('[data-cy-open-post-form]').click();
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
cy.get('[data-cy-open-post-form-submit]').click();
cy.contains('Hello, Misskey!', { timeout: 15000 });
});
it('open note form with hotkey', () => {
// Wait until the page loads
cy.get('[data-cy-open-post-form]').should('be.visible');
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
// See if the form is opened
cy.get('[data-cy-post-form-text]').should('be.visible');
// Close it
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
// See if the form is closed
cy.get('[data-cy-post-form-text]').should('not.be.visible');
});
});
// TODO: 投稿フォームの公開範囲指定のテスト
// TODO: 投稿フォームのファイル添付のテスト
// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト

View File

@@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Router transition', () => {
describe('Redirect', () => {
// サーバの初期化。ルートのテストに関しては各describeごとに1度だけ実行で十分だと思う使いまわした方が早い
before(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.wait(500);
cy.get('[data-cy-modal-dialog-ok]').click();
});
it('redirect to user profile', () => {
// テストのためだけに用意されたリダイレクト用ルートに飛ぶ
cy.visit('/redirect-test');
// プロフィールページのURLであることを確認する
cy.url().should('include', '/@alice')
});
});
});

View File

@@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* flaky
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
cy.viewport('macbook-16');
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('widget edit toggle is visible', () => {
cy.get('[data-cy-widget-edit]').should('be.visible');
});
it('widget select should be visible in edit mode', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select]').should('be.visible');
});
it('first widget should be removed', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click();
cy.get('[data-cy-customize-container]').should('have.length', 2);
});
function buildWidgetTest(widgetName) {
it(`${widgetName} widget should get added`, () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select] select').select(widgetName, { force: true });
cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true });
cy.get('[data-cy-widget-add]').click({ force: true });
cy.get(`[data-cy-mkw-${widgetName}]`).should('exist');
});
}
buildWidgetTest('memo');
buildWidgetTest('notifications');
buildWidgetTest('timeline');
buildWidgetTest('calendar');
buildWidgetTest('rss');
buildWidgetTest('trends');
buildWidgetTest('clock');
buildWidgetTest('activity');
buildWidgetTest('photos');
buildWidgetTest('digitalClock');
buildWidgetTest('federation');
buildWidgetTest('postForm');
buildWidgetTest('slideshow');
buildWidgetTest('serverMetric');
buildWidgetTest('onlineUsers');
buildWidgetTest('jobQueue');
buildWidgetTest('button');
buildWidgetTest('aiscript');
buildWidgetTest('aichan');
});
*/

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,67 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('visitHome', () => {
cy.visit('/');
cy.get('button', { timeout: 30000 }).should('be.visible');
})
Cypress.Commands.add('resetState', () => {
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
/*
cy.window().then(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
*/
cy.request('POST', '/api/reset-db', {}).as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
});
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
cy.request('POST', route, {
username: username,
password: password,
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
}).its('body').as(username);
});
Cypress.Commands.add('login', (username, password) => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn');
});

View File

@@ -1,34 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err, runnable) => {
if ([
'The source image cannot be decoded',
// Chrome
'ResizeObserver loop limit exceeded',
// Firefox
'ResizeObserver loop completed with undelivered notifications',
].some(msg => err.message.includes(msg))) {
return false;
}
});

View File

@@ -1,19 +0,0 @@
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
registerUser(
username: string,
password: string,
isAdmin?: boolean
): Chainable<void>;
resetState(): Chainable<void>;
visitHome(): Chainable<void>;
}
}
}
export {}

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["dom"],
"target": "esnext",
"types": ["cypress", "node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -0,0 +1,176 @@
# Frontend bundle report: generated chunk handling
## Background
The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`.
Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:<name>`, so later entries overwrite earlier entries. This produces two problems:
1. unrelated chunks can be compared as if they were the same chunk; and
2. overwritten chunks are omitted from the reported total.
Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching.
## Goals
- Never compare unrelated generated chunks as the same chunk.
- Keep generated chunks out of the individual chunk-diff rows.
- Preserve the contribution of every physical JavaScript chunk in totals.
- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible.
- Apply the same rules to the full chunk report and the startup chunk report.
- Continue comparing source-backed chunks and intentionally named chunks.
## Non-goals
- Inferring chunk identity from module-set similarity.
- Preserving an `updated` relationship for every shared chunk after code splitting changes.
- Changing the frontend's code-splitting strategy or output filenames.
- Suppressing specific names such as `esm` or `dist` with a denylist.
## Approaches considered
### Denylist generated-looking names
Exclude names such as `esm`, `dist`, `lib`, and `index`.
This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place.
### Compare only stable identities and aggregate the rest
Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate.
This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible.
### Match shared chunks by their module sets
Use visualizer metadata to compare exact or similar sets of module IDs.
This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient.
## Design
### Separate physical chunks from comparable identities
`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection.
Each physical chunk contains:
- its resolved relative output path;
- its byte size;
- its manifest key, when present;
- its display name; and
- an optional comparison key.
The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds.
### Comparison-key classification
A chunk receives a comparison key only in one of these cases:
1. `chunk.src` is present: use `src:<normalized source path>`.
2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:<name>`.
The initial stable-name allowlist is:
- `vue`
- `i18n`
These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`.
All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key.
If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison.
### Full chunk report
The report computes three independent values:
1. **Total:** the sum of every unique physical JavaScript chunk.
2. **Generated chunk aggregate:** the sum of chunks without a comparison key.
3. **Individual rows:** before/after comparisons for stable comparison keys only.
The table starts with the existing `(total)` row. Generated chunks are rendered at the bottom of the table as one aggregate row such as:
```text
(other generated chunks)
```
This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. No additional generated-chunk count note is rendered below the table.
### Small-delta aggregation
Stable comparison rows whose absolute byte delta is at most `5 B` are grouped into an `(other)` aggregate instead of being rendered individually. The threshold is inclusive: deltas from `-5 B` through `+5 B` are grouped, while a `6 B` absolute delta remains an individual row. Small additions and removals are handled by the same rule.
The `(other)` row reports the sum of the grouped chunks' before sizes and the sum of their after sizes. It does not expose an arbitrary representative filename. In the full chunk report, only changed rows are candidates because unchanged rows are already omitted. In the startup report, all rows currently eligible for display are candidates, so unchanged rows are also grouped instead of being listed individually.
The updated/added/removed counts are calculated before small-delta rows are grouped. Therefore the summary continues to include every stable changed chunk, including chunks represented only by `(other)`.
Table rows are ordered as follows:
1. `(total)`;
2. one empty separator row;
3. individual stable comparison rows whose absolute delta is greater than `5 B`;
4. `(other generated chunks)`, when generated chunks exist; and
5. `(other)`, when small-delta stable chunks exist.
There is no additional separator row before the aggregate rows. If no individual stable row exists, the empty row after `(total)` is therefore immediately followed by the aggregate rows.
The existing 30-row limit applies after small-delta rows have been removed from the individual-row candidates.
### Startup chunk report
Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one.
Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table.
### Displayed filenames
For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file.
The display can use one filename when unchanged and `before → after` when the content-hashed filename differs.
The generated aggregate row does not display an arbitrary representative filename.
## Data flow
1. Read each build's Vite manifest.
2. Resolve localized output files and collect unique physical chunks.
3. Attach optional stable comparison keys using the classification rules.
4. Traverse startup imports using manifest identities and map them to physical chunks.
5. Calculate full and startup totals from physical chunks.
6. Calculate generated aggregates from chunks without comparison keys.
7. Compare only unique stable keys for individual rows.
8. Render totals, generated aggregates, and stable rows.
## Error handling
- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error.
- Duplicate physical output paths are de-duplicated and counted once.
- Duplicate stable comparison keys fail with an error that includes the key and conflicting files.
- Missing or malformed manifest data remains fatal rather than producing a partial report.
## Testing
Add focused fixtures or pure-function tests covering:
- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate;
- `dist` and `esm` chunks never produce individual comparison rows;
- source-backed chunks with the same `src` are reported as updated;
- source-backed additions and removals remain visible;
- allowlisted `vue` and `i18n` chunks remain individually comparable;
- duplicate stable keys produce a descriptive error instead of overwriting;
- full totals equal the sum of all unique physical chunks;
- startup totals include multiple same-name generated chunks exactly once each;
- generated aggregate changes do not affect the updated/added/removed summary counts;
- differing before/after filenames are rendered without attributing both sizes to one file;
- deltas of exactly `5 B` are grouped into `(other)`, while `6 B` deltas remain individual;
- small updated, added, and removed chunks remain included in the summary counts;
- an empty separator row appears immediately after `(total)`, with no additional separator before `(other generated chunks)` or `(other)`;
- no generated-chunk grouping note is rendered below the table; and
- the same small-delta and ordering rules apply to the full and startup tables.
Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior.
## Future extension
If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity.

View File

@@ -619,6 +619,8 @@ output: "出力"
script: "スクリプト"
disablePagesScript: "Pagesのスクリプトを無効にする"
updateRemoteUser: "リモートユーザー情報の更新"
unsetMfa: "二要素認証を解除"
unsetMfaConfirm: "二要素認証を解除しますか?"
unsetUserAvatar: "アイコンを解除"
unsetUserAvatarConfirm: "アイコンを解除しますか?"
unsetUserBanner: "バナーを解除"
@@ -1361,14 +1363,11 @@ information: "情報"
chat: "チャット"
directMessage: "ダイレクトメッセージ"
directMessage_short: "メッセージ"
migrateOldSettings: "旧設定情報を移行"
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
compress: "圧縮"
right: "右"
bottom: "下"
top: "上"
embed: "埋め込み"
settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)"
readonly: "読み取り専用"
goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
@@ -1420,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
_imageEditing:
_vars:
@@ -1790,12 +1791,6 @@ _serverSettings:
entrancePageStyle: "エントランスページのスタイル"
showTimelineForVisitor: "タイムラインを表示する"
showActivitiesForVisitor: "アクティビティを表示する"
features: "機能"
_spReactions:
enable: "スペシャルリアクションを有効にする"
description1: "通常のリアクションより目立つ「スペシャルリアクション」をノートに送れる機能です。"
description2: "有効にする場合、ロールポリシーで、毎月送ることのできる最大数を設定してください。"
_userGeneratedContentsVisibilityForVisitor:
all: "全て公開"
@@ -2183,6 +2178,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。"
analyzeVideos: "動画の解析を有効化"
analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。"
externalServiceInfo: "センシティブメディアの判定は外部サービス (sensitive-detector) に分離されました。この機能を利用するには、別途サイドカーサービスをセットアップし、下記の接続先を設定する必要があります。接続先が未設定の場合、判定は行われません (非センシティブ扱い)。"
apiUrl: "判定サービスの接続先URL"
apiUrlDescription: "sensitive-detector サービスのベースURL (例: http://localhost:3009)。プライベートネットワーク上のサービスに接続する場合は、設定ファイルの allowedPrivateNetworks で接続先ネットワークを許可してください。プロキシを使用している場合は、proxyBypassHosts も設定してください。空欄の場合、センシティブ判定は行われません。"
apiKey: "APIキー"
apiKeyDescription: "判定サービス側で認証 (Bearerトークン) を設定している場合に入力します。設定していない場合は空欄のままにしてください。"
timeout: "タイムアウト (ミリ秒)"
timeoutDescription: "判定リクエスト1回あたりのタイムアウト時間です。"
maxImagesPerRequest: "1リクエストあたりの最大画像数"
maxImagesPerRequestDescription: "動画など複数フレームを判定する際、1回のリクエストにまとめて送る画像の最大枚数です。これを超える分は分割して順次送信されます。sensitive-detector 側の maxParts 設定(デフォルト: 10を超えないように設定してください。超えた場合、そのチャンクは全件非センシティブ扱いとなります。"
_emailUnavailable:
used: "既に使用されています"
@@ -2523,6 +2527,7 @@ _permissions:
"read:admin:show-moderation-log": "モデレーションログを見る"
"read:admin:show-user": "ユーザーのプライベートな情報を見る"
"write:admin:suspend-user": "ユーザーを凍結する"
"write:admin:unset-mfa": "ユーザーの二要素認証を解除する"
"write:admin:unset-user-avatar": "ユーザーのアバターを削除する"
"write:admin:unset-user-banner": "ユーザーのバーナーを削除する"
"write:admin:unsuspend-user": "ユーザーの凍結を解除する"
@@ -2910,7 +2915,6 @@ _notification:
renote: "リノート"
quote: "引用"
reaction: "リアクション"
spReaction: "スペシャルリアクション"
pollEnded: "アンケートが終了"
scheduledNotePosted: "予約投稿が成功した"
scheduledNotePostFailed: "予約投稿が失敗した"
@@ -3069,6 +3073,7 @@ _moderationLogTypes:
createAvatarDecoration: "アイコンデコレーションを作成"
updateAvatarDecoration: "アイコンデコレーションを更新"
deleteAvatarDecoration: "アイコンデコレーションを削除"
unsetMfa: "ユーザーの二要素認証を解除"
unsetUserAvatar: "ユーザーのアイコンを解除"
unsetUserBanner: "ユーザーのバナーを解除"
createSystemWebhook: "SystemWebhookを作成"

View File

@@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "2026.6.0",
"version": "2026.7.0-alpha.6",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@11.5.2",
"packageManager": "pnpm@11.9.0",
"workspaces": [
"packages/misskey-js",
"packages/i18n",
@@ -39,11 +39,10 @@
"migrateandstart": "pnpm migrate && pnpm start",
"watch": "pnpm dev",
"dev": "node scripts/dev.mjs",
"lint": "pnpm --no-bail -r lint",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"backend-unit-test": "cd packages/backend && pnpm test",
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
"test": "pnpm -r test",
@@ -53,33 +52,25 @@
"cleanall": "pnpm clean-all"
},
"dependencies": {
"cssnano": "8.0.1",
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "8.0.0",
"js-yaml": "4.2.0",
"postcss": "8.5.15",
"tar": "7.5.16",
"terser": "5.48.0"
"ignore-walk": "9.0.0",
"js-yaml": "5.2.0",
"tar": "7.5.19"
},
"devDependencies": {
"@eslint/js": "9.39.4",
"@misskey-dev/eslint-plugin": "2.1.0",
"@types/js-yaml": "4.0.9",
"@types/node": "24.13.1",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.17.0",
"eslint": "9.39.4",
"globals": "17.6.0",
"globals": "17.7.0",
"ncp": "2.0.0",
"pnpm": "11.5.2",
"start-server-and-test": "3.0.9",
"pnpm": "11.9.0",
"start-server-and-test": "3.0.11",
"typescript": "5.9.3"
},
"optionalDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
}

View File

@@ -1,36 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SpReactions1769664628306 {
name = 'SpReactions1769664628306'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "note_sp_reaction" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "reaction" character varying(260) NOT NULL, "noteUserId" character varying(32) NOT NULL, CONSTRAINT "PK_11fd5ecdd9bb91517edfcf890d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "note_sp_reaction"."noteUserId" IS '[Denormalized]'`);
await queryRunner.query(`CREATE INDEX "IDX_3463a48b09fa41e1826ebd9f58" ON "note_sp_reaction" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_bfe4caa46cc0526bc2932d6dbe" ON "note_sp_reaction" ("noteId") `);
await queryRunner.query(`CREATE INDEX "IDX_8be6eb3f4edc9940a3f8142669" ON "note_sp_reaction" ("noteUserId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b5f210b20bd987fe8584c85d33" ON "note_sp_reaction" ("userId", "noteId") `);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableSpReaction" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "note_sp_reaction" ADD CONSTRAINT "FK_3463a48b09fa41e1826ebd9f585" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_sp_reaction" ADD CONSTRAINT "FK_bfe4caa46cc0526bc2932d6dbed" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_sp_reaction" DROP CONSTRAINT "FK_bfe4caa46cc0526bc2932d6dbed"`);
await queryRunner.query(`ALTER TABLE "note_sp_reaction" DROP CONSTRAINT "FK_3463a48b09fa41e1826ebd9f585"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableSpReaction"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b5f210b20bd987fe8584c85d33"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8be6eb3f4edc9940a3f8142669"`);
await queryRunner.query(`DROP INDEX "public"."IDX_bfe4caa46cc0526bc2932d6dbe"`);
await queryRunner.query(`DROP INDEX "public"."IDX_3463a48b09fa41e1826ebd9f58"`);
await queryRunner.query(`DROP TABLE "note_sp_reaction"`);
}
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SensitiveMediaDetectionExternalService1780488454126 {
name = 'SensitiveMediaDetectionExternalService1780488454126'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionApiUrl" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionApiKey" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionTimeout" integer NOT NULL DEFAULT '60000'`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionMaxImagesPerRequest" integer NOT NULL DEFAULT '4'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionMaxImagesPerRequest"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionTimeout"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionApiKey"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionApiUrl"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UrlPreviewSensitiveList1782581064131 {
name = 'UrlPreviewSensitiveList1782581064131'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": "^22.15.0 || ^24.10.0"
"node": "^22.22.2 || ^24.17.0 || ^26.4.0"
},
"scripts": {
"start": "pnpm compile-config && node ./built/entry.js",
@@ -34,8 +34,6 @@
"generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
},
"optionalDependencies": {
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.1.0",
"slacc-android-arm-eabi": "0.1.5",
"slacc-android-arm64": "0.1.5",
@@ -53,36 +51,43 @@
"utf-8-validate": "6.0.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1065.0",
"@aws-sdk/lib-storage": "3.1065.0",
"@aws-sdk/client-s3": "3.1075.0",
"@aws-sdk/lib-storage": "3.1075.0",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/http-proxy": "11.5.0",
"@fastify/multipart": "10.0.0",
"@fastify/otel": "0.19.0",
"@fastify/static": "9.1.3",
"@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/sharp-read-bmp": "1.3.1",
"@misskey-dev/summaly": "5.5.1",
"@napi-rs/canvas": "1.0.0",
"@nestjs/common": "11.1.26",
"@nestjs/core": "11.1.26",
"@nestjs/testing": "11.1.26",
"@oxc-project/runtime": "0.135.0",
"@napi-rs/canvas": "1.0.1",
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.214.0",
"@opentelemetry/instrumentation-pg": "0.72.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/sdk-trace-node": "2.7.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@oxc-project/runtime": "0.137.0",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.57.0",
"@sentry/profiling-node": "10.57.0",
"@simplewebauthn/server": "13.3.1",
"@sinonjs/fake-timers": "15.4.0",
"@smithy/node-http-handler": "4.7.7",
"@sentry/node": "10.62.0",
"@sentry/profiling-node": "10.62.0",
"@simplewebauthn/server": "13.3.2",
"@smithy/node-http-handler": "4.8.2",
"accepts": "1.3.8",
"ajv": "8.20.0",
"archiver": "8.0.0",
"async-mutex": "0.5.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"bullmq": "5.78.0",
"bullmq": "5.79.2",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -91,13 +96,12 @@
"content-disposition": "2.0.1",
"date-fns": "4.4.0",
"deep-email-validator": "0.1.27",
"fastify": "5.8.5",
"fastify": "5.9.0",
"fastify-raw-body": "5.0.0",
"feed": "5.2.1",
"file-type": "22.0.1",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.6",
"got": "15.0.5",
"got": "15.0.7",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
@@ -107,41 +111,39 @@
"is-svg": "6.1.0",
"json5": "2.2.3",
"jsonld": "9.0.0",
"juice": "12.1.0",
"juice": "12.1.1",
"meilisearch": "0.58.0",
"mfm-js": "0.26.0",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.11",
"nanoid": "5.1.16",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.1.0",
"nodemailer": "8.0.10",
"nsfwjs": "4.3.0",
"node-html-parser": "8.0.3",
"nodemailer": "9.0.1",
"os-utils": "0.0.14",
"otpauth": "9.5.1",
"pg": "8.21.0",
"pg": "8.22.0",
"pkce-challenge": "6.0.0",
"probe-image-size": "7.3.0",
"promise-limit": "2.7.0",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.24.1",
"re2": "1.25.0",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.2",
"sanitize-html": "2.17.4",
"sanitize-html": "2.17.5",
"secure-json-parse": "4.1.0",
"semver": "7.8.4",
"sharp": "0.33.5",
"semver": "7.8.5",
"sharp": "0.35.2",
"slacc": "0.1.5",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.31.7",
"systeminformation": "5.31.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.7",
"tsc-alias": "1.8.17",
@@ -154,19 +156,19 @@
},
"devDependencies": {
"@kitajs/ts-html-plugin": "4.1.4",
"@nestjs/platform-express": "11.1.26",
"@nestjs/platform-express": "11.1.27",
"@rollup/plugin-esm-shim": "0.1.8",
"@sentry/vue": "10.57.0",
"@sentry/vue": "10.62.0",
"@sinonjs/fake-timers": "15.4.0",
"@types/accepts": "1.3.7",
"@types/archiver": "8.0.0",
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.13.1",
"@types/nodemailer": "8.0.0",
"@types/node": "26.0.1",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
@@ -176,28 +178,26 @@
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "7.2.0",
"@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@vitest/coverage-v8": "4.1.8",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.12",
"cbor2": "2.3.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "4.2.0",
"js-yaml": "5.2.0",
"pid-port": "2.1.1",
"rolldown": "1.1.0",
"rolldown": "1.1.3",
"simple-oauth2": "5.1.0",
"supertest": "7.2.2",
"vite": "8.0.16",
"vitest": "4.1.8",
"vite": "8.1.0",
"vitest": "4.1.9",
"vitest-mock-extended": "4.0.0"
}
}

View File

@@ -3,6 +3,7 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
import fkill from 'fkill';
import esmShim from '@rollup/plugin-esm-shim';
/**
@@ -10,6 +11,7 @@ import esmShim from '@rollup/plugin-esm-shim';
*/
function backendDevServerPlugin(): Plugin {
let backendProcess: ResultPromise | null = null;
let backendShutdownPromise: Promise<void> | null = null;
async function runBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
@@ -20,12 +22,31 @@ function backendDevServerPlugin(): Plugin {
}
async function killBackendProcess() {
if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill();
await new Promise(resolve => backendProcess!.on('exit', resolve));
backendProcess = null;
}
if (backendShutdownPromise) return backendShutdownPromise;
if (!backendProcess) return;
const processToKill = backendProcess;
backendProcess = null;
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
backendShutdownPromise = (async () => {
if (process.platform === 'win32' && processToKill.pid != null) {
await fkill(processToKill.pid, {
force: true,
tree: true,
silent: true,
waitForExit: 5000,
});
} else {
processToKill.kill();
}
await processToKill.catch(() => {});
})().finally(() => {
backendShutdownPromise = null;
});
return backendShutdownPromise;
}
return {
@@ -49,6 +70,9 @@ function backendDevServerPlugin(): Plugin {
await runBuildAssets();
}
},
async closeWatcher() {
await killBackendProcess();
},
};
}
@@ -63,18 +87,11 @@ export default defineConfig((args) => {
'class-validator',
/^@sentry\/.*/,
/^@sentry-internal\/.*/,
/^@opentelemetry\/.*/,
'@nestjs/websockets/socket-module',
'@nestjs/microservices/microservices-module',
'@nestjs/microservices',
/^@napi-rs\/.*/,
// @tensorflow/tfjs-node はネイティブバインディングを持つため external 必須 (#17501)。
// あわせて nsfwjs と @tensorflow/* 全体を external にする。bundle 内の nsfwjs が
// 抱える @tensorflow/tfjs-core と、external な tfjs-node が使う tfjs-core が
// 別インスタンスに分裂すると、tfjs-node が登録する file:// IOHandler を nsfwjs 側が
// 共有できず、モデル読み込みが HTTP handler(node-fetch) にフォールバックして
// 「URL scheme "file" is not supported」で失敗するため。
/^@tensorflow\/.*/,
'nsfwjs',
'mock-aws-s3',
'aws-sdk',
'nock',
@@ -83,6 +100,8 @@ export default defineConfig((args) => {
're2',
'ipaddr.js',
'file-type',
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
'pg',
];
const define: Record<string, string> = {

View File

@@ -11,7 +11,7 @@
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { load as loadYaml } from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -34,7 +34,7 @@ function yamlToJson(ymlPath) {
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
const jsonContent = loadYaml(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}

View File

@@ -4,15 +4,15 @@
*/
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 { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
//import * as http from 'node:http';
import * as fs from 'node:fs/promises';
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
import { measureMemoryUntilStable } from '../../../.github/scripts/memory-stability-util.mts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const backendDir = process.env.MK_MEMORY_BACKEND_DIR == null || process.env.MK_MEMORY_BACKEND_DIR === ''
? join(import.meta.dirname, '..')
: resolve(process.env.MK_MEMORY_BACKEND_DIR);
function readIntegerEnv(name, defaultValue, min) {
const rawValue = process.env[name];
@@ -32,65 +32,39 @@ function readBooleanEnv(name, defaultValue) {
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 HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH;
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
const heapSnapshotCategories = [
'Code',
'Strings',
'JS arrays',
'Typed arrays',
'System objects',
'Other JS objects',
'Other non-JS objects',
'Total',
];
type GcMessage = 'gc ok' | 'gc unavailable';
type RuntimeMemoryUsageMessage = {
type: 'memory usage';
value: NodeJS.MemoryUsage;
};
type HeapSnapshotMessage = {
type: 'heap snapshot';
path?: string;
};
type HeapSnapshotErrorMessage = {
type: 'heap snapshot error';
message: string;
};
type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
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> {
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string): 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) {
} else {
throw new Error(`Failed to parse ${key} from ${path}`);
}
}
@@ -101,181 +75,44 @@ function bytesToKiB(value: number) {
return Math.round(value / 1024);
}
function createEmptyHeapSnapshotCategoryMap() {
return Object.fromEntries(heapSnapshotCategories.map(category => [category, 0]));
}
function isTypedArrayNode(type, name) {
return typedArrayNames.has(name) ||
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
}
function isSystemNode(type, name) {
return type === 'hidden' ||
type === 'synthetic' ||
type === 'object shape' ||
name.startsWith('system /') ||
name.startsWith('(system ');
}
function classifyHeapSnapshotNode(type, name) {
if (type === 'code') return 'Code';
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'Strings';
if (isTypedArrayNode(type, name)) return 'Typed arrays';
if (type === 'array' || (type === 'object' && name === 'Array')) return 'JS arrays';
if (isSystemNode(type, name)) return 'System objects';
if (otherJsNodeTypes.has(type)) return 'Other JS objects';
return 'Other non-JS objects';
}
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
if (label.length <= 80) return label;
return `${label.slice(0, 77)}...`;
}
function classifyHeapSnapshotBreakdown(category, type, name) {
if (category === 'Strings') return type;
if (category === 'JS arrays') {
if (type === 'array') return 'array nodes';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'Typed arrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
if (typedArrayNames.has(name)) return name;
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'System objects') {
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'Other JS objects') {
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
return type;
}
if (category === 'Other non-JS objects') {
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'Code') {
const lowerName = name.toLowerCase();
if (lowerName.includes('bytecode')) return 'bytecode';
if (lowerName.includes('builtin')) return 'builtins';
if (lowerName.includes('regexp')) return 'regexp code';
if (lowerName.includes('stub')) return 'stubs';
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
}
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
function collapseHeapSnapshotBreakdown(breakdowns) {
const collapsed = {};
for (const [category, children] of Object.entries(breakdowns)) {
const entries = Object.entries(children)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const categoryBreakdown = Object.fromEntries(topEntries);
if (otherValue > 0) categoryBreakdown.Other = otherValue;
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
}
return collapsed;
}
function analyzeHeapSnapshot(snapshot) {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) {
throw new Error('Invalid heap snapshot format');
}
const nodeFields = meta.node_fields;
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const fieldCount = nodeFields.length;
const categories = createEmptyHeapSnapshotCategoryMap();
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
const breakdowns = Object.fromEntries(
heapSnapshotCategories
.filter(category => category !== 'Total')
.map(category => [category, {}]),
);
function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
const name = strings[nodes[offset + nameOffset]] ?? '';
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
const category = classifyHeapSnapshotNode(type, name);
categories[category] += selfSize;
categories.Total += selfSize;
nodeCounts[category]++;
nodeCounts.Total++;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
}
return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
};
}
async function getMemoryUsage(pid: number) {
const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8');
return parseMemoryFile(status, procStatusKeys, path, true);
return parseMemoryFile(status, procStatusKeys, path);
}
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);
return parseMemoryFile(smapsRollup, smapsRollupKeys, path);
}
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
return new Promise((resolve, reject) => {
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object';
}
function isGcMessage(message: unknown): message is GcMessage {
return message === 'gc ok' || message === 'gc unavailable';
}
function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage {
return isRecord(message) && message.type === 'memory usage' && isRecord(message.value);
}
function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage {
if (!isRecord(message)) return false;
if (message.type === 'heap snapshot') return true;
return message.type === 'heap snapshot error' && typeof message.message === 'string';
}
function waitForMessage<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) {
return new Promise<T>((resolve, reject) => {
const timer = globalThis.setTimeout(() => {
serverProcess.off('message', onMessage);
reject(new Error(`Timed out waiting for ${description}`));
}, timeout);
const onMessage = (message) => {
const onMessage = (message: unknown) => {
if (!predicate(message)) return;
globalThis.clearTimeout(timer);
serverProcess.off('message', onMessage);
@@ -289,7 +126,7 @@ function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIM
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
const response = waitForMessage(
serverProcess,
message => message != null && typeof message === 'object' && message.type === 'memory usage',
isRuntimeMemoryUsageMessage,
'memory usage',
);
@@ -306,13 +143,13 @@ async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
};
}
async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
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'),
isHeapSnapshotResponseMessage,
'heap snapshot',
HEAP_SNAPSHOT_TIMEOUT,
);
@@ -330,8 +167,13 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
try {
if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') {
await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true });
await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH);
}
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
return analyzeHeapSnapshot(snapshot);
return analyzeHeapSnapshot(snapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N });
} finally {
await fs.unlink(writtenPath).catch(err => {
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
@@ -341,17 +183,23 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
async function getAllMemoryUsage(serverProcess: ChildProcess) {
const pid = serverProcess.pid!;
const stableSmapsRollup = await measureMemoryUntilStable(
() => getSmapsRollupMemoryUsage(pid),
);
return {
...await getMemoryUsage(pid),
...await getSmapsRollupMemoryUsage(pid),
...await getRuntimeMemoryUsage(serverProcess),
memoryUsage: {
...await getMemoryUsage(pid),
...stableSmapsRollup.memoryUsage,
...await getRuntimeMemoryUsage(serverProcess),
},
stability: stableSmapsRollup.stability,
};
}
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
cwd: join(__dirname, '..'),
const serverProcess = fork(join(backendDir, 'built/entry.js'), [], {
cwd: backendDir,
env: {
...process.env,
NODE_ENV: 'production',
@@ -363,16 +211,13 @@ async function measureMemory() {
execArgv: [...process.execArgv, '--expose-gc'],
});
let serverReady = false;
const serverReady = waitForMessage(
serverProcess,
(message): message is 'ok' => message === 'ok',
'server startup',
STARTUP_TIMEOUT,
);
// 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}`);
});
@@ -381,7 +226,6 @@ async function measureMemory() {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
@@ -389,7 +233,7 @@ async function measureMemory() {
async function triggerGc() {
const ok = waitForMessage(
serverProcess,
message => message === 'gc ok' || message === 'gc unavailable',
isGcMessage,
'GC completion',
);
@@ -399,141 +243,55 @@ async function measureMemory() {
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);
try {
await serverReady;
} catch (err) {
serverProcess.kill('SIGTERM');
throw err;
}
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 afterGc = await getAllMemoryUsage(serverProcess);
process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`);
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 serverExited = new Promise<void>(resolve => {
const timer = globalThis.setTimeout(() => {
serverProcess.kill('SIGKILL');
resolve();
}, 10000);
serverProcess.once('exit', () => {
globalThis.clearTimeout(timer);
resolve();
});
});
serverProcess.kill('SIGTERM');
await serverExited;
const result = {
return {
timestamp: new Date().toISOString(),
phases: {
//beforeGc,
afterGc: {
memoryUsage: memoryUsageAfterGC,
memoryUsage: afterGc.memoryUsage,
memoryStability: afterGc.stability,
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>>[];
};
export type MemorySample = 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));
console.log(JSON.stringify(await measureMemory(), null, 2));
}
main().catch((err) => {

View File

@@ -26,7 +26,6 @@ declare module 'probe-image-size' {
function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void;
function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void;
namespace probeImageSize {} // Hack
export = probeImageSize;
// eslint-disable-next-line import/no-default-export
export { probeImageSize as default };
}

View File

@@ -10,10 +10,11 @@
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';
import { envOption } from '../env.js';
import { installProcessErrorHandlers } from './process-error-handler.js';
import { isShutdownInProgress } from './shutdown-handler.js';
import { readyRef } from './ready.js';
import 'reflect-metadata';
@@ -27,6 +28,8 @@ const logger = new Logger('core', 'cyan');
const clusterLogger = logger.createSubLogger('cluster', 'orange');
const ev = new Xev();
installProcessErrorHandlers({ logger, quiet: envOption.quiet });
//#region Events
// Listen new workers
@@ -41,27 +44,19 @@ cluster.on('online', worker => {
// Listen for dying workers
cluster.on('exit', worker => {
// Replace the dead worker,
// we're not sentimental
clusterLogger.error(chalk.red(`[${worker.id}] died :(`));
if (isShutdownInProgress()) {
clusterLogger.info(`Process exited during shutdown: [${worker.id}]`);
return;
}
// 終了したワーカーは従来どおり再生成し、表示色は出力処理へ任せます。
clusterLogger.error(`[${worker.id}] died :(`);
cluster.fork();
});
// Display detail of unhandled promise rejection
if (!envOption.quiet) {
process.on('unhandledRejection', console.dir);
}
// Display detail of uncaught exception
process.on('uncaughtException', err => {
try {
logger.error(err);
console.trace(err);
} catch { }
});
// Dying away...
process.on('exit', code => {
if (isShutdownInProgress()) return;
logger.info(`The process is going to exit with code ${code}`);
});
@@ -69,12 +64,10 @@ process.on('exit', code => {
if (!envOption.disableClustering) {
if (cluster.isPrimary) {
logger.info(`Start main process... pid: ${process.pid}`);
const { masterMain } = await import('./master.js');
await masterMain();
ev.mount();
} else if (cluster.isWorker) {
logger.info(`Start worker process... pid: ${process.pid}`);
const { workerMain } = await import('./worker.js');
await workerMain();
} else {
@@ -82,7 +75,6 @@ if (!envOption.disableClustering) {
}
} else {
// 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない)
logger.info(`Start main process... pid: ${process.pid}`);
const { masterMain } = await import('./master.js');
await masterMain();
ev.mount();

View File

@@ -11,16 +11,30 @@ import chalkTemplate from 'chalk-template';
import Logger from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js';
import type { LogFormat } from '@/logging/types.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
import { installShutdownSignalHandlers } from './shutdown-handler.js';
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
const themeColor = chalk.hex('#86b300');
function greet(props: { version: string }) {
/** 起動時の案内を、選択されたログ形式に合わせて出力します。 */
function greet(props: { version: string; format: LogFormat }) {
if (!envOption.quiet && props.format === 'json') {
// JSONモードでは生のコンソール出力を避け、各案内を1件ずつ構造化ログにします。
bootLogger.info('Welcome to Misskey!');
bootLogger.info(`Misskey v${props.version}`, null, true);
bootLogger.info('Misskey is an open-source decentralized microblogging platform.');
bootLogger.info('If you like Misskey, please consider donating to support dev. https://misskey-hub.net/docs/donate/');
return;
}
if (!envOption.quiet) {
//#region Misskey logo
const v = `v${props.version}`;
@@ -51,7 +65,9 @@ export async function masterMain() {
// initialize app
try {
config = loadConfigBoot();
greet({ version: config.version });
logger.info(`Start main process... pid: ${process.pid}`);
bootLogger.createSubLogger('config').succ('Loaded');
greet({ version: config.version, format: config.logging?.format ?? 'pretty' });
showEnvironment();
await showMachineInfo(bootLogger);
showNodejsVersion();
@@ -66,26 +82,16 @@ export async function masterMain() {
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
try {
await initTelemetry(config);
} catch (e) {
bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
process.exit(1);
}
installShutdownSignalHandlers({
shutdownTasks: [shutdownTelemetry, shutdownLogging],
onRegistered: message => bootLogger.info(message),
});
bootLogger.info(
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,
@@ -143,12 +149,14 @@ function showNodejsVersion(): void {
nodejsLogger.info(`Version ${process.version} detected.`);
}
/** 設定を読み込み、成功時に後続のログ出力形式を適用します。 */
function loadConfigBoot(): Config {
const configLogger = bootLogger.createSubLogger('config');
let config;
try {
config = loadConfig();
configureLogging(config.logging);
} catch (exception) {
if (typeof exception === 'string') {
configLogger.error(exception);
@@ -160,8 +168,6 @@ function loadConfigBoot(): Config {
throw exception;
}
configLogger.succ('Loaded');
return config;
}

View File

@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LogWriteInput } from '@/logging/types.js';
/** プロセス例外をロガーへ渡すために必要な最小の処理対象です。 */
export type ProcessErrorHandlerProcess = {
on(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown;
on(event: 'uncaughtException', listener: (error: Error) => void): unknown;
};
/** 例外記録に必要なロガーの最小インターフェースです。 */
export type ProcessErrorHandlerLogger = {
write(input: LogWriteInput): void;
};
/** プロセス例外ハンドラーの登録に使う依存関係です。 */
export type ProcessErrorHandlerOptions = {
readonly process?: ProcessErrorHandlerProcess;
readonly logger: ProcessErrorHandlerLogger;
readonly quiet: boolean;
};
/** ログ記録の失敗が別の例外を起こさないよう、プロセス異常を安全に記録します。 */
function writeProcessError(
logger: ProcessErrorHandlerLogger,
eventName: 'process.unhandled_rejection' | 'process.uncaught_exception',
message: string,
error: unknown,
): void {
try {
// 元の値をerrorへ渡し、JSON形式の出力処理で安全な形へ正規化します。
logger.write({
level: 'error',
eventName,
message,
error,
});
} catch {
// 例外処理中のログ失敗で、さらにプロセスを不安定にしないよう握りつぶします。
}
}
/** 未処理のPromise拒否と未捕捉例外を構造化ログへ接続します。 */
export function installProcessErrorHandlers(options: ProcessErrorHandlerOptions): void {
const processLike: ProcessErrorHandlerProcess = options.process ?? (process as unknown as ProcessErrorHandlerProcess);
if (!options.quiet) {
processLike.on('unhandledRejection', reason => {
writeProcessError(options.logger, 'process.unhandled_rejection', 'Unhandled promise rejection', reason);
});
}
processLike.on('uncaughtException', error => {
writeProcessError(options.logger, 'process.uncaught_exception', 'Uncaught exception', error);
});
}

View File

@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type ShutdownSignalProcess = {
once(event: 'SIGTERM' | 'SIGINT', listener: () => Promise<void>): unknown;
};
const SHUTDOWN_TIMEOUT_MS = 10_000;
export type ShutdownTask = () => Promise<void>;
export type ShutdownHandlerOptions = {
/** The process-like object that receives the signal handlers. */
process?: ShutdownSignalProcess;
/** Shutdown tasks, executed in array order. */
shutdownTasks: readonly ShutdownTask[];
/** Process termination function. */
exit?: (code: number) => void;
/** Optional boot logger hook used after signal handlers are registered. */
onRegistered?: (message: string) => void;
};
let shuttingDown = false;
/**
* Register the process-level shutdown signals.
*
* Boot owns signal coordination and receives shutdown tasks through callbacks
* so individual domains do not depend on each other.
*
* 注意: このプロジェクトでは app.enableShutdownHooks() が一切呼ばれていないため、
* NestJSのOnApplicationShutdown経由のgraceful shutdown(GlobalModule.dispose()によるDB/Redis切断、
* QueueProcessorService.stop()によるqueue drain、ServerService.dispose()によるfastify/WebSocket close)は
* SIGTERM/SIGINTを起点には発火しない。このhandlerはそれらを経由せず、登録された終了処理を実行して即exitする。
* 将来enableShutdownHooks()を配線する場合は、この即exitとNestJS側のshutdown sequenceが競合しないよう順序を設計すること。
*/
export function installShutdownSignalHandlers(options: ShutdownHandlerOptions): void {
// テストではprocess/exitを差し替え、本番では実processにSIGTERM/SIGINT handlerを登録する。
const processLike = options.process ?? process;
const exit = options.exit ?? ((code: number) => process.exit(code));
const handleSignal = async () => {
// 同時に複数signalが来てもflushを二重実行せず、cluster refork抑止用の状態もここで立てる。
if (shuttingDown) return;
shuttingDown = true;
let timedOut = false;
let timeout: NodeJS.Timeout | undefined;
try {
// 処理時間上限つきのシャットダウンプロセス
await Promise.race([
(async () => {
for (const shutdownTask of options.shutdownTasks) {
if (timedOut) return;
try {
await shutdownTask();
} catch (error) {
// 1つの終了処理の失敗で後続タスクを妨げないよう、stderrへフォールバックする。
try {
console.error('Shutdown task failed:', error);
} catch {
// stderrの出力自体が失敗しても、残りの終了処理とexitは継続する。
}
}
}
})(),
new Promise<void>(resolve => {
timeout = setTimeout(() => {
timedOut = true;
try {
console.error(`Shutdown tasks timed out after ${SHUTDOWN_TIMEOUT_MS}ms.`);
} catch {
// stderrの出力自体が失敗してもexitは継続する。
}
resolve();
}, SHUTDOWN_TIMEOUT_MS);
}),
]);
} finally {
if (timeout != null) clearTimeout(timeout);
}
// 既存挙動と同じく、終了処理後はプロセスを終了する。
exit(0);
};
// onceにして、同じsignalでhandlerが再入しないようにする。
processLike.once('SIGTERM', handleSignal);
processLike.once('SIGINT', handleSignal);
// app.enableShutdownHooks()未配線の現状、SIGTERM/SIGINT時には登録済み終了処理のみを行う。
options.onRegistered?.('Registered SIGTERM/SIGINT shutdown handler (this process does not perform NestJS graceful shutdown on these signals).');
}
export function isShutdownInProgress(): boolean {
// masterのcluster exit handlerが、意図したshutdown中のworker終了を再forkしないために参照する。
return shuttingDown;
}

View File

@@ -4,38 +4,45 @@
*/
import cluster from 'node:cluster';
import Logger from '@/logger.js';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js';
import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js';
import { initExtraThreadPool, jobQueue, server } from './common.js';
import { installShutdownSignalHandlers } from './shutdown-handler.js';
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
/**
* Init worker process
*/
export async function workerMain() {
const config = loadConfig();
let config: Config;
try {
config = loadConfig();
configureLogging(config.logging);
logger.info(`Start worker process... pid: ${process.pid}`);
} catch (e) {
bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
process.exit(1);
return;
}
initExtraThreadPool(config);
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
try {
await initTelemetry(config);
} catch (e) {
bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true);
process.exit(1);
}
installShutdownSignalHandlers({
shutdownTasks: [shutdownTelemetry, shutdownLogging],
onRegistered: message => bootLogger.info(message),
});
if (envOption.onlyServer) {
await server();

View File

@@ -10,6 +10,7 @@ import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis';
import type { LogFormat, LogLevelSetting } from './logging/types.js';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
@@ -20,6 +21,27 @@ type RedisOptionsSource = Partial<RedisOptions> & {
prefix?: string;
};
type SentryBackendConfig = {
options: Partial<Sentry.NodeOptions>;
enableNodeProfiling: boolean;
disabledIntegrations?: string[];
};
type OtelBackendConfig = {
endpoint?: string;
headers?: Record<string, string>;
sampleRate?: number;
capturePgSpans?: boolean;
capturePgStatement?: boolean;
capturePgConnectionSpans?: boolean;
captureRedisCommandSpans?: boolean;
captureRedisConnectionSpans?: boolean;
captureRedisRootSpans?: boolean;
resourceAttributes?: Record<string, string>;
propagateTraceToRemote?: boolean;
jobTraceContextMode?: 'link' | 'parent';
};
/**
* 設定ファイルの型
*/
@@ -64,7 +86,8 @@ type Source = {
index: string;
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
sentryForBackend?: SentryBackendConfig;
otelForBackend?: OtelBackendConfig;
sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -110,6 +133,9 @@ type Source = {
pidFile: string;
logging?: {
format?: LogFormat;
level?: LogLevelSetting;
domains?: Record<string, LogLevelSetting> | null;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
@@ -172,6 +198,9 @@ export type Config = {
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
logging?: {
format?: LogFormat;
level?: LogLevelSetting;
domains?: Record<string, LogLevelSetting> | null;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
@@ -201,7 +230,8 @@ export type Config = {
redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
sentryForBackend: SentryBackendConfig | undefined;
otelForBackend: OtelBackendConfig | undefined;
sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -307,6 +337,7 @@ export function loadConfig(): Config {
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
sentryForBackend: config.sentryForBackend,
otelForBackend: config.otelForBackend,
sentryForFrontend: config.sentryForFrontend,
id: config.id,
proxy: config.proxy,

Some files were not shown because too many files have changed in this diff Show More