Compare commits

...

1234 Commits

Author SHA1 Message Date
syuilo c35a6c01ff wip 2026-07-30 20:39:15 +09:00
syuilo f75c12fbd3 wip 2026-07-30 18:08:23 +09:00
syuilo 755a630468 Update engine.ts 2026-07-30 13:24:42 +09:00
syuilo c2bb96e180 wip 2026-07-30 13:24:31 +09:00
syuilo 64d721d24a wip 2026-07-30 12:58:06 +09:00
syuilo a716d5511a wip 2026-07-30 10:42:27 +09:00
syuilo cb62a1d4fe wip 2026-07-30 10:11:29 +09:00
syuilo 6ec6a5d523 Merge branch 'develop' into room 2026-07-30 09:38:45 +09:00
syuilo 3bbe40baec Update Firework.ts 2026-07-29 20:09:08 +09:00
syuilo 2d7e79f924 wip 2026-07-29 20:01:43 +09:00
syuilo b3c981d14a wip 2026-07-29 17:18:28 +09:00
かっこかり bcc3bc4fd7 fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように (#17822)
* fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように

* fix

* fix
2026-07-29 14:47:58 +09:00
syuilo 1b878998a0 wip 2026-07-29 14:20:58 +09:00
syuilo dc773a2709 wip 2026-07-29 12:48:57 +09:00
syuilo 9feab0b673 wip 2026-07-29 11:39:54 +09:00
おさむのひと 6f579b6854 chore: docker_example.ymlのsentryForBackend更新が漏れていたのを追記 (#17817) 2026-07-28 21:25:08 +09:00
syuilo c1d60fa5f5 up 2026-07-28 20:39:09 +09:00
syuilo d93aede642 wip 2026-07-28 19:45:15 +09:00
github-actions[bot] d600525bb2 Bump version to 2026.7.0-rc.0 2026-07-28 10:21:11 +00:00
おさむのひと 6dac6f7928 fix: OTel除去 (#17812) 2026-07-28 19:15:44 +09:00
かっこかり 318af2e1ff fix(frontend): follow-up of #17811 (#17814) 2026-07-28 17:35:33 +09:00
かっこかり a44e419d5a fix(frontend): follow-up of 2509a28 (#17815) 2026-07-28 17:35:18 +09:00
syuilo 3479b957e4 wip 2026-07-28 16:39:33 +09:00
syuilo 4682180c8b wip 2026-07-28 14:56:51 +09:00
かっこかり 2509a28813 Merge commit from fork 2026-07-28 11:11:56 +09:00
おさむのひと a12ee2e5d7 Merge commit from fork 2026-07-28 11:09:37 +09:00
syuilo 6dffa2f59a wip 2026-07-28 11:08:25 +09:00
syuilo 86762db14e wip 2026-07-28 10:47:40 +09:00
syuilo f53b50ee54 Update config.json 2026-07-27 21:55:29 +09:00
syuilo 58cb722bdf wip 2026-07-27 21:54:15 +09:00
syuilo ee2d16b237 Update lobby.ts 2026-07-27 20:44:13 +09:00
syuilo 319ddc674c wip 2026-07-27 20:07:24 +09:00
かっこかり 703aa2360b enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように (#17811)
* enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように

* clean up
2026-07-27 18:34:31 +09:00
かっこかり cb58ff32a4 fix(gh): check-misskey-js-autogenが失敗する問題を修正 (#17809)
* fix(gh): check-misskey-js-autogenが失敗する問題を修正

* fix comment

* fix
2026-07-27 16:48:36 +09:00
syuilo 9d4d04b6d2 Update lobby.ts 2026-07-27 16:18:46 +09:00
syuilo b9ca53785c wip 2026-07-27 16:00:11 +09:00
syuilo 83f3daccde Merge branch 'develop' into room 2026-07-27 12:58:26 +09:00
かっこかり f6979333d4 docs: Update CHANGELOG.md to include about js-yaml changes (#17805) 2026-07-27 00:14:34 +09:00
かっこかり e897255e71 fix 2026-07-25 21:43:41 +09:00
syuilo daf2a85539 Update CHANGELOG.md 2026-07-25 21:37:20 +09:00
chocolate-pie 1a59ec20e3 Merge commit from fork
* fix: Fix improper authorization in `admin/reset-password`

* Apply suggestions from code review

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* fix: improper authorization in `admin/unset-mfa`

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-25 21:34:46 +09:00
4ster1sk 3e31e59bcd fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように (#17802)
* test(e2e): フォロー中のチャンネルに再度フォローAPIを叩いた場合のテストを追加

* fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように

* docs(changelog): update changelog
2026-07-25 21:09:12 +09:00
anatawa12 982d4905c0 use UPSERT instead of select for hashtag statistics update (#17795)
* feat: set defaults for hashtag table

* chore: use upsert to update hashtag table

* fix: query runner not cleaned up

* fix: query runner not cleaned up correctly

* chore: replace await using => try-finally
2026-07-25 19:48:20 +09:00
renovate[bot] 7ecce0901c fix(deps): update dependency js-yaml to v5.2.2 [security] (#17797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 16:07:49 +09:00
github-actions[bot] c70fb20b9e Bump version to 2026.7.0-beta.6 2026-07-25 01:29:48 +00:00
renovate[bot] cde2fe24c4 fix(deps): update dependency tar to v7.5.21 [security] (#17798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 10:27:21 +09:00
renovate[bot] e8951b7aa1 fix(deps): update dependency @fastify/static to v10.1.2 [security] (#17796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 09:27:15 +09:00
renovate[bot] d2973ecdca chore(deps): update [github actions] update dependencies (major) (#17792)
chore(deps): update [github actions] update dependencies

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 22:46:54 +09:00
renovate[bot] cf7a6b6efc chore(deps): update [github actions] update dependencies (#17791)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 21:40:01 +09:00
syuilo 7c29a18d61 enhance(frontend): アップロード前のファイルのプレビューに対応&アップロード後のプレビューにMkLightboxを使用 (#17794)
* wip

* [ci skip] Update CHANGELOG.md

* Update MkUploaderItems.vue

* Update MkUploaderItems.vue

* Update MkUploaderItems.vue
2026-07-24 21:39:28 +09:00
syuilo 16ca5a72a6 New Crowdin updates (#17733)
* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (English)

[ci skip]

* New translations ja-jp.yml (Korean)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Italian)

[ci skip]

* New translations ja-jp.yml (Italian)

[ci skip]

* New translations ja-jp.yml (Italian)

[ci skip]

* New translations ja-jp.yml (Catalan)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Spanish)

[ci skip]
2026-07-24 11:26:05 +09:00
syuilo db8ee16e03 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-24 10:06:57 +09:00
syuilo 521f6006fe add note 2026-07-24 10:06:56 +09:00
かっこかり 19fa9a24ab revert(frontend): "タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように" (#17787)
Revert "perf(frontend): タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように (#17708)"

This reverts commit 152e0fc48c.
2026-07-24 09:56:39 +09:00
github-actions[bot] f68da80ebd Bump version to 2026.7.0-beta.5 2026-07-24 00:37:34 +00:00
かっこかり fcb4ced33d enhance(frontend): tagcanvas実装を変更 (#17753)
* enhance(frontend): tagcanvas実装を変更

* fix indent

* fix

* fix

* review fixes
2026-07-24 09:30:35 +09:00
おさむのひと 85e810533b enhance: 旧Loggerを拡張して構造化ログを直接受け取れるように (#17780)
* enhance: 旧Loggerを拡張して構造化ログを直接受け取れるように

* fix lint
2026-07-24 09:28:56 +09:00
かっこかり 661838c89e fix(backend): 初期設定で作成したアカウント以外でアカウント作成APIが利用できない問題を修正 (#17783)
* fix(backend): 初期設定で作成したアカウント以外でアカウント作成APIが利用できない問題を修正

* Update Changelog

* fix

* fix
2026-07-24 09:27:40 +09:00
かっこかり 80d14231c5 fix(frontend): Lightbox上で強くスワイプしたときに一時的に画面が操作不能になる問題を修正 (#17785)
* fix(frontend): Lightbox上で強くスワイプしたときに一時的に画面が操作不能になる問題を修正

* fix

* update comment
2026-07-23 21:55:20 +09:00
syuilo 28153f7b5f Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-23 21:46:20 +09:00
syuilo 6d81c81ab2 chore(gh): fix tests 2026-07-23 21:46:19 +09:00
github-actions[bot] 6f54416f63 Bump version to 2026.7.0-beta.4 2026-07-23 12:33:02 +00:00
かっこかり a63c8ef939 fix(frontend): センシティブなメディアの表示が常にぼかすになっている場合に初回表示した画像がぼかされる問題を修正 (#17781) 2026-07-23 20:03:10 +09:00
かっこかり 015868f17f 【テスト中】fix(frontend): 画像の上をタップした状態でPull to Refreshを行うと画像も開かれてしまう問題を修正 (#17784)
* fix(frontend): 画像の上をタップした状態でPull to Refreshを行うと画像も開かれてしまう問題を修正

* attempt to fix

* revert

* revert
2026-07-23 20:01:33 +09:00
かっこかり dc3fd42d63 fix(frontend): follow-up of #17775 2026-07-23 17:27:14 +09:00
かっこかり 34a4b9b74c Update .coderabbit.yaml [ci skip] 2026-07-23 17:19:19 +09:00
syuilo 4a7ff6d7f4 chore(gh): use nbsp for table 2026-07-23 14:58:08 +09:00
syuilo 3d87032881 chore(gh): use nbsp for table
狭い画面だと変な場所で自動改行されて崩れるため
2026-07-23 11:10:30 +09:00
github-actions[bot] dc3f2a0012 Bump version to 2026.7.0-beta.3 2026-07-22 13:33:07 +00:00
かっこかり 79b4a73679 fix(frontend): いくつかのイベントリスナーの解除処理の修正 (#17768)
* fix(frontend): いくつかのイベントリスナーの解除処理の修正

* fix
2026-07-22 22:28:01 +09:00
かっこかり 170ee2bfeb perf(frontend): タブがバックグラウンドな間のsetIntervalを停止するように (#17769) 2026-07-22 22:24:14 +09:00
かっこかり c9ac4f9b41 fix(frontend): MkNoteDetailedで削除された引用元が表示されない問題を修正 (#17776)
* fix(frontend): MkNoteDetailedで削除された引用元が表示されない問題を修正

* Update Changelog

* fix
2026-07-22 22:18:01 +09:00
おさむのひと d5a09b3919 enhance: APIアクセスログを出力可能なように(opt-in) (#17771)
* enhance: APIアクセスログを出力可能なように(opt-in)

* fix
2026-07-22 22:17:29 +09:00
かっこかり 5cb6661484 fix(frontend): StreamingTimelineのインスタンスが保持された状態でページを行き来するとアニメーションが再度再生される問題を修正 (#17775)
* fix(frontend): StreamingTimelineのインスタンスが保持された状態でページを行き来するとアニメーションが再度再生される問題を修正

* fix
2026-07-22 21:13:27 +09:00
github-actions[bot] 575390e612 Bump version to 2026.7.0-beta.2 2026-07-22 01:38:50 +00:00
かっこかり c6eb30cfb8 fix(frontend): QRコードリーダーがページを離れても停止しない問題を修正 (#17761)
* fix(frontend): QRコードリーダーがページを離れても停止しない問題を修正

* Update Changelog

* fix

* fix
2026-07-22 10:17:57 +09:00
かっこかり 48216d343e perf(frontend): MkInput/MkSelectの幅調整にResizeObserverを使用するように (#17762)
perf(frontend): MkInput/MkSelectの定期実行の幅調整にResizeObserverを使用するように

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-22 10:17:07 +09:00
syuilo 82cca1124f Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-22 10:16:29 +09:00
かっこかり 2d2aa779ad fix(frontend): チャートコンポーネントのマウント解除時にChartインスタンスが破棄されていない問題を修正 (#17763)
* fix(frontend): Chartのマウント解除時にChartインスタンスが破棄されていない問題を修正

* Update Changelog

* fix
2026-07-22 10:16:15 +09:00
syuilo eb7626924e Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-22 10:15:42 +09:00
syuilo 7725110e1c chore(gh): tweak workflow 2026-07-22 10:15:19 +09:00
renovate[bot] 4c3f40a87d fix(deps): update dependency typeorm to v1.1.0 [security] (#17767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 10:12:34 +09:00
かっこかり 5e3977973f docs: update changelog (#17754)
Update CHANGELOG.md
2026-07-22 09:35:45 +09:00
syuilo 98ae62c432 enhance(gh): 複数サンプルの値の表示を改善するなど (#17759)
* docs: design backend diagnostics memory noise handling

* chore(gh): tweak workflow

* docs: clarify shared heap snapshot reporting

* docs: plan backend diagnostics memory noise handling

* chore: ignore local worktrees

* feat(diagnostics): add independent delta summary

* feat(diagnostics): make heap snapshot deltas noise-aware

* docs: correct heap snapshot threshold plan

* fix(diagnostics): suppress backend memory noise

* ci: refine backend diagnostics sampling

* test(diagnostics): address final review

* wip

* Update heap-snapshot-sampling.ts

* wip

* wip

* docs: design frontend metric noise reporting

* docs: plan frontend metric noise reporting

* fix(diagnostics): suppress frontend metric noise

* docs: remove temporary frontend metric plans

* test(diagnostics): cover frontend metric thresholds

* docs: design shared diagnostics metric table

* docs: plan shared diagnostics metric table

* refactor(diagnostics): simplify independent delta statistics

* feat(diagnostics): add shared metric table renderer

* refactor(diagnostics): use shared heap snapshot table

* refactor(diagnostics): use shared backend metric table

* refactor(diagnostics): use shared frontend metric table

* docs: remove temporary metric table plans

* tweak

* docs: design metric table no-data output

* fix(diagnostics): show marker for empty metric tables

* chore: remove temporary diagnostics design
2026-07-21 22:43:22 +09:00
Soli 41e730a763 fix(backend): 本番ビルドで @opentelemetry/instrumentation が解決できない問題を修正 (#17756)
Signed-off-by: Soli0222 <github@str08.net>
2026-07-21 18:48:58 +09:00
syuilo b51d5ba0a0 frontend bundle diagnostics と frontend browser diagnostics を統合 (#17757)
* refactor(gh): unify frontend diagnostics

* refactor(gh): unify frontend diagnostics markdown rendering

* docs(gh): restore frontend diagnostics comments

* wip

* fix

* refactor: 呼称をbase/headに統一

* tweak

* Update types.ts

* fix
2026-07-21 11:26:53 +09:00
syuilo 159b1a4484 Update .coderabbit.yaml 2026-07-21 10:30:46 +09:00
syuilo f138a48419 Update .coderabbit.yaml 2026-07-21 10:09:21 +09:00
syuilo 1dcd6c287c Update .coderabbit.yaml 2026-07-21 10:06:26 +09:00
syuilo ccd04fbcd9 Update .coderabbit.yaml 2026-07-21 10:03:23 +09:00
syuilo b2d07ca6e3 Update .coderabbit.yaml 2026-07-21 10:01:21 +09:00
かっこかり ab369784fb refactor(gh): CI用スクリプトをpackageとして整理 (#17727)
* refactor(gh): CI用スクリプトをpackageとして整理

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* remove old scripts

* migrate

* refactor 1

* refactor 2

* fix comment

* fix

* fix

* fix

* fix

* remove vite-node from changelog-checker

* fix lint

* fix

* refactor

* update deps

* fix

* spec: rename packages
2026-07-20 20:09:22 +09:00
かっこかり 7157f37011 Update CHANGELOG.md [ci skip] 2026-07-20 19:38:47 +09:00
おさむのひと bbd57c751c enhance: OTel設定時、JSONログにtrace_id, span_id, trace_flagがつくように (#17748)
* enhance: OTel設定時、JSONログにtrace_id, span_id, trace_flagがつくように

* fix
2026-07-20 19:00:54 +09:00
syuilo 6fac907867 Update babylonRuntime.ts 2026-07-03 15:48:54 +09:00
syuilo 887f219119 up 2026-07-03 15:42:05 +09:00
syuilo b1a1717fdc Merge branch 'develop' into room 2026-07-03 15:40:38 +09:00
syuilo 1903607659 thumbs 2026-06-17 16:55:13 +09:00
syuilo 7d750668c0 thumbs 2026-06-17 16:51:17 +09:00
syuilo db6708bfbd Merge branch 'develop' into room 2026-06-17 16:42:41 +09:00
syuilo bb06278e97 wip 2026-06-15 20:13:43 +09:00
syuilo 3fd8becdc5 typo 2026-06-15 18:51:26 +09:00
syuilo de94c15c8f typo 2026-06-15 18:50:05 +09:00
syuilo 68719db32f typo 2026-06-15 18:49:26 +09:00
syuilo 1e4539a168 typo 2026-06-15 18:48:21 +09:00
syuilo ef6c5dfc95 typo 2026-06-15 18:47:33 +09:00
syuilo 4fc9e38e0d typo 2026-06-15 18:46:54 +09:00
syuilo 60f60b6bac Merge branch 'develop' into room 2026-06-15 18:42:48 +09:00
syuilo a7a7b8bc69 Update babylonRuntime.ts 2026-06-15 17:42:36 +09:00
syuilo 253dd052f8 refactoe 2026-06-15 16:22:57 +09:00
syuilo c1eb67a4ce typo 2026-06-15 16:18:20 +09:00
syuilo a26d78d573 stickyPlaneId 2026-06-15 16:16:33 +09:00
syuilo 9e33507f01 wip 2026-06-14 20:10:00 +09:00
syuilo bde03b8259 wip 2026-06-14 17:21:05 +09:00
syuilo 5690d28d4e Update engine.ts 2026-06-14 17:04:17 +09:00
syuilo 64a4283c35 Update engine.ts 2026-06-14 15:31:05 +09:00
syuilo 9de48030fc Update engine.ts 2026-06-14 15:28:38 +09:00
syuilo 1dbf9f9f85 wip 2026-06-14 13:35:21 +09:00
syuilo 37c3145859 shadow 2026-06-13 20:34:04 +09:00
syuilo 600dae3d68 wip 2026-06-13 15:59:23 +09:00
syuilo ab9d6947ae 🎨 2026-06-13 12:31:35 +09:00
syuilo 56da1e5084 🎨 2026-06-13 11:35:24 +09:00
syuilo 63d314750f Update room.core.vue 2026-06-12 18:43:02 +09:00
syuilo 1e10b67476 herbarium 2026-06-12 17:42:11 +09:00
syuilo cf2139d515 wip 2026-06-12 13:39:41 +09:00
syuilo 3d2941bd8f wip 2026-06-12 12:30:39 +09:00
syuilo c06eef6b58 username label 2026-06-11 21:27:32 +09:00
syuilo fa7a8b1282 up 2026-06-11 20:13:42 +09:00
syuilo 319826fe76 Merge branch 'develop' into room 2026-06-11 20:12:32 +09:00
syuilo 54cfa4a13e Update README.md 2026-06-11 18:35:54 +09:00
syuilo e507d41640 Update lavaLamp.ts 2026-06-11 18:24:58 +09:00
syuilo d9cac0fa56 🎨 2026-06-11 14:47:22 +09:00
syuilo 2e51cde16e 🎨 2026-06-11 11:58:43 +09:00
syuilo 98ac383b44 🎨 2026-06-11 11:25:32 +09:00
syuilo e1290584f2 kakejiku 2026-06-10 20:51:41 +09:00
syuilo 923d95d078 locale 2026-06-10 18:59:13 +09:00
syuilo 47a5f0cdd9 image rotation 2026-06-10 18:50:20 +09:00
syuilo 961d611cd4 bolt 2026-06-10 15:50:47 +09:00
syuilo 2bc10ccc34 lavaLamp 2026-06-10 13:58:20 +09:00
syuilo 38dae938c0 Update haniwa.ts 2026-06-09 20:15:02 +09:00
syuilo 1f2b635920 clean up 2026-06-09 19:51:03 +09:00
syuilo 39c6baf6f2 room light 2026-06-09 18:50:46 +09:00
syuilo 79541bcbe3 downlight 2026-06-09 15:29:26 +09:00
syuilo ba66b5d609 wip 2026-06-09 14:09:10 +09:00
syuilo a2c96c29c7 wip 2026-06-09 12:06:50 +09:00
syuilo 60db0a6bbd wip 2026-06-09 11:40:35 +09:00
syuilo 07f17820b3 Update simple.ts 2026-06-08 21:50:06 +09:00
syuilo 66cd678cd3 wip 2026-06-08 21:35:29 +09:00
syuilo 1f604db203 wip 2026-06-08 15:45:27 +09:00
syuilo 06def2995b throttle 2026-06-08 11:45:59 +09:00
syuilo c9107e5d2b Update MkWorldMonoOptionsForm.vue 2026-06-08 11:41:16 +09:00
syuilo cbac208262 Update README.md 2026-06-08 10:37:44 +09:00
syuilo b7c7323b97 Update README.md 2026-06-08 10:28:59 +09:00
syuilo 602d034e94 cardboard-box 2026-06-08 10:24:23 +09:00
syuilo 22f78ab175 haniwa 2026-06-07 20:46:01 +09:00
syuilo d59afb5a29 ceilingFan 2026-06-07 18:31:01 +09:00
syuilo cc8e6a2e92 update ceiling-fan-light 2026-06-07 16:59:09 +09:00
syuilo 004ef368c2 update ceiling-fan-light 2026-06-07 16:47:04 +09:00
syuilo ac06c1700a Update babylonRuntime.ts
add RegisterAnimation and RegisterAnimatable
2026-06-07 11:24:43 +09:00
syuilo 2d0fb674d5 wip 2026-06-06 17:29:08 +09:00
syuilo 61b8b0aac3 .js 2026-06-06 17:17:15 +09:00
syuilo bd6e443cec Update haniwa.ts 2026-06-06 17:00:20 +09:00
syuilo 897199a807 .js 2026-06-06 16:45:49 +09:00
syuilo 1f64579516 wip 2026-06-06 16:34:16 +09:00
syuilo 84203630a1 wip 2026-06-05 20:48:20 +09:00
syuilo 561e0c3813 Update room.add-furniture-dialog.vue 2026-06-04 22:50:17 +09:00
syuilo 169d655717 Update room.add-furniture-dialog.vue 2026-06-04 22:27:30 +09:00
syuilo ade652dd3e wallClock 2026-06-04 20:22:09 +09:00
syuilo 138353c84d up 2026-06-04 16:53:02 +09:00
syuilo 4b30bfeb3c wip 2026-06-04 16:22:31 +09:00
syuilo aae2039df2 Update default.blend 2026-06-04 02:28:39 +09:00
syuilo 850e156687 🎨 2026-06-04 02:28:30 +09:00
syuilo 844196cfea wip 2026-06-03 22:07:04 +09:00
syuilo 1925840f3f Merge branch 'develop' into room 2026-06-03 21:54:33 +09:00
syuilo d5df006463 Update babylonRuntime.ts 2026-06-03 21:46:22 +09:00
syuilo 2593652510 Update babylonRuntime.ts 2026-06-03 21:38:52 +09:00
syuilo 2e22b34c40 Create haniwa.blend 2026-06-03 21:01:51 +09:00
syuilo 8d3c3284b5 wip 2026-06-03 19:15:08 +09:00
syuilo 7d7a125299 wip 2026-06-03 18:46:18 +09:00
syuilo aac51a171c wip 2026-06-03 17:12:08 +09:00
syuilo 908e0022e9 wip 2026-06-03 15:14:25 +09:00
syuilo b56a2a8bf7 wip 2026-06-03 14:50:32 +09:00
syuilo 61de0fb279 wip 2026-06-03 14:17:23 +09:00
syuilo b43ff3d820 wip 2026-06-03 14:06:41 +09:00
syuilo 23b2f4c7a0 Update env.ts 2026-06-03 13:53:56 +09:00
syuilo 827895b548 wip 2026-06-03 13:00:53 +09:00
syuilo 8b0dd1ea68 wip 2026-06-03 12:03:54 +09:00
syuilo ec3517ba58 wip 2026-06-03 09:55:12 +09:00
syuilo c24515044e Update babylonRuntime.ts 2026-06-03 08:48:33 +09:00
syuilo f55adc19d3 registerBabylonRuntime 2026-06-03 08:42:07 +09:00
syuilo 32af098cd9 pure 2026-06-02 21:41:11 +09:00
syuilo cfea88dce3 Update defineFurnitureUi.ts 2026-06-02 20:31:27 +09:00
syuilo 3a97e26f80 Update engine.ts 2026-06-02 18:50:58 +09:00
syuilo 7bcb1b1732 wip 2026-06-02 18:00:39 +09:00
syuilo 8684cf14cc wip 2026-06-02 17:43:20 +09:00
syuilo 686a847697 wip 2026-06-02 17:06:10 +09:00
syuilo 0294ef74e4 wip 2026-06-02 14:53:23 +09:00
syuilo 42af31fbbd Update room.core.vue 2026-06-02 14:47:20 +09:00
syuilo b9ea42531a wip 2026-06-02 14:39:15 +09:00
syuilo 39c4618a35 Merge branch 'develop' into room 2026-06-02 14:12:58 +09:00
syuilo b025ce0ae6 wip 2026-06-02 10:50:24 +09:00
syuilo 07e660df01 Update MkWorldAvatarEditDialog.vue 2026-06-01 20:28:21 +09:00
syuilo e07eed4a4b wip 2026-06-01 18:33:50 +09:00
syuilo 19ad95cfa6 Update room.core.vue 2026-06-01 13:13:56 +09:00
syuilo 8f5f595073 Update room.core.vue 2026-06-01 13:13:32 +09:00
syuilo 6a536d5c92 refactor 2026-06-01 13:12:39 +09:00
syuilo c1e6e6a871 wip 2026-06-01 13:05:35 +09:00
syuilo 039bce590f wip 2026-06-01 12:44:46 +09:00
syuilo 24dac68475 Update MkWorldAvatarManager.vue 2026-06-01 11:21:23 +09:00
syuilo 8217a3a42c wip 2026-06-01 11:08:50 +09:00
syuilo dbcb856638 wip 2026-06-01 11:07:05 +09:00
syuilo dbbb197816 wip 2026-06-01 10:58:13 +09:00
syuilo 40263b0af5 wip 2026-06-01 09:42:07 +09:00
syuilo b978df8990 wip 2026-06-01 09:26:07 +09:00
syuilo 3a0da02b50 wip 2026-06-01 09:17:24 +09:00
syuilo af7d1a763a wip 2026-06-01 09:15:58 +09:00
syuilo a7e16c09dd up 2026-05-31 20:07:47 +09:00
syuilo 351872f47b Update engine.ts 2026-05-31 18:57:23 +09:00
syuilo e3c6c3025a wip 2026-05-31 18:52:54 +09:00
syuilo 273bc59cdb Update room.core.vue 2026-05-31 18:49:24 +09:00
syuilo 7a3e27d653 Update PlayerContainer.ts 2026-05-31 18:39:57 +09:00
syuilo 6a8aacfb95 wip 2026-05-31 17:07:39 +09:00
syuilo 99659a89b4 Update room.core.vue 2026-05-31 16:56:40 +09:00
syuilo 4591b940c3 Update room.core.vue 2026-05-31 16:52:23 +09:00
syuilo f01e9aa2ac wip 2026-05-31 16:50:33 +09:00
syuilo bba1605801 Update WorldRoomMultiplayService.ts 2026-05-31 16:48:06 +09:00
syuilo 6d6dbb2584 Update PlayerContainer.ts 2026-05-31 16:47:07 +09:00
syuilo 51a08bbf34 Update room.core.vue 2026-05-31 16:17:27 +09:00
syuilo 7fa8bcc9fd wip 2026-05-31 16:09:58 +09:00
syuilo 9d5818f7b5 wip 2026-05-31 15:24:55 +09:00
syuilo bb028fda00 wip 2026-05-31 14:56:40 +09:00
syuilo 56ae76e994 Update OverlayPanel.vue 2026-05-31 14:39:20 +09:00
syuilo 69bc12201e wip 2026-05-31 14:35:15 +09:00
syuilo 313e730fbb Update room.core.vue 2026-05-31 14:24:24 +09:00
syuilo c14986b43d Update room.core.vue 2026-05-31 14:22:59 +09:00
syuilo 69c75fc464 Update room.core.vue 2026-05-31 13:39:47 +09:00
syuilo 1b13c945ff wip 2026-05-31 13:37:40 +09:00
syuilo 6e763d4daa Update OverlayPanel.vue 2026-05-31 13:28:12 +09:00
syuilo d1e5b04a10 Update OverlayPanel.vue 2026-05-31 13:27:32 +09:00
syuilo 7fec78b70e wip 2026-05-31 13:22:54 +09:00
syuilo c962cd33b3 wip 2026-05-31 12:47:24 +09:00
syuilo 814bf357ca wip 2026-05-31 11:30:32 +09:00
syuilo 222d914544 Update room.core.vue 2026-05-31 11:29:22 +09:00
syuilo b81b9e1da7 wip 2026-05-31 11:16:56 +09:00
syuilo 52cb23da63 wip 2026-05-31 10:09:25 +09:00
syuilo 6b4c2c9141 Update room.core.vue 2026-05-31 10:02:09 +09:00
syuilo 7af9069962 wip 2026-05-31 09:58:26 +09:00
syuilo 8f9be8c735 wip 2026-05-31 09:40:20 +09:00
syuilo cdbf5c843f wip 2026-05-31 09:26:19 +09:00
syuilo 27fff648b6 Update README.md 2026-05-30 21:17:27 +09:00
syuilo 5cdc5f53b4 Update MkWorldMonoOptionsForm.vue 2026-05-30 20:45:26 +09:00
syuilo c67e231ff5 wip 2026-05-30 19:18:43 +09:00
syuilo f564b7a29b object -> furniture 2026-05-30 19:14:45 +09:00
syuilo 1eea31aacd object -> furniture 2026-05-30 19:00:43 +09:00
syuilo 5da458ca54 Update engine.ts 2026-05-30 18:32:39 +09:00
syuilo c343ccbdff wip 2026-05-30 18:25:16 +09:00
syuilo f8a981a1fc wip 2026-05-30 18:20:04 +09:00
syuilo 72df3d29ce wip 2026-05-29 10:57:17 +09:00
syuilo 75454b8075 wip 2026-05-29 10:08:08 +09:00
syuilo 559846fd7c wip 2026-05-29 09:56:22 +09:00
syuilo 8c1fa69800 Update MkWorldAvatarEditDialog.vue 2026-05-29 09:44:11 +09:00
syuilo 810d437a2c Update MkWorldAvatarEditDialog.vue 2026-05-29 09:40:33 +09:00
syuilo 3fca2cb664 wip 2026-05-29 08:01:39 +09:00
syuilo e95552b264 wip 2026-05-29 07:42:48 +09:00
syuilo 781f6135fd wip 2026-05-29 07:39:52 +09:00
syuilo e90830c738 Update MkWorldAvatarEditDialog.vue 2026-05-28 21:02:26 +09:00
syuilo ab32ab6746 wip 2026-05-28 20:52:10 +09:00
syuilo 86c4f3072f wip 2026-05-28 18:27:44 +09:00
syuilo 8bb8b2fc97 wip 2026-05-28 17:50:04 +09:00
syuilo fad53328be wip 2026-05-28 17:42:54 +09:00
syuilo 0a6bfce548 wip 2026-05-28 17:32:45 +09:00
syuilo 5998b01ffc wip 2026-05-28 17:24:22 +09:00
syuilo 9a91170839 wip 2026-05-28 15:03:06 +09:00
syuilo a0aa64cd9f wip 2026-05-28 13:12:35 +09:00
syuilo 95864071a0 Update PlayerContainer.ts 2026-05-28 10:31:01 +09:00
syuilo ff9d997cec Update edit-world-avatar-dialog.vue 2026-05-28 10:16:33 +09:00
syuilo c6d0855a2e wip 2026-05-28 10:15:02 +09:00
syuilo 624b858758 wip 2026-05-28 09:56:40 +09:00
syuilo d722b8e9d7 wip 2026-05-28 09:40:02 +09:00
syuilo fd02ea906f wip 2026-05-28 09:24:03 +09:00
syuilo aed9a4a1b4 Update preferences.vue 2026-05-28 08:54:35 +09:00
syuilo ace51f7605 wip 2026-05-28 08:53:14 +09:00
syuilo c10e1c4b31 wip 2026-05-28 08:51:23 +09:00
syuilo 4c80e92522 wip 2026-05-28 08:04:50 +09:00
syuilo 71c3f921cc wip 2026-05-28 07:53:30 +09:00
syuilo 6bc9eb9d84 wip 2026-05-27 21:12:34 +09:00
syuilo fbf3c02392 wip 2026-05-27 21:00:05 +09:00
syuilo 0c8b1055d4 wip 2026-05-27 19:54:40 +09:00
syuilo 0e7b517554 wip 2026-05-27 19:18:55 +09:00
syuilo 5cefdd224b wip 2026-05-27 18:58:50 +09:00
syuilo 1a5a4c834f wip 2026-05-27 17:58:34 +09:00
syuilo 2df145c458 wip 2026-05-27 17:28:32 +09:00
syuilo 43958fc70d wip 2026-05-27 16:57:11 +09:00
syuilo 8f94b04edc Update WorldRoomService.ts 2026-05-27 10:11:03 +09:00
syuilo c7d07d0364 Update WorldRoomService.ts 2026-05-27 10:06:59 +09:00
syuilo 5d974e8242 wip 2026-05-27 09:56:17 +09:00
syuilo 807ade4c37 wip 2026-05-27 09:55:12 +09:00
syuilo 2efd52f400 wip 2026-05-26 15:00:14 +09:00
syuilo f0375aa420 wip 2026-05-26 14:42:19 +09:00
syuilo 04c2703ace wip 2026-05-26 10:51:07 +09:00
syuilo 65022ed342 wip 2026-05-26 10:44:40 +09:00
syuilo 0d025d99f2 Update ObjectContainer.ts 2026-05-26 10:43:20 +09:00
syuilo e68847e563 wip 2026-05-26 10:42:40 +09:00
syuilo 409e9f744c wip 2026-05-26 10:01:50 +09:00
syuilo 9f81a88466 wip 2026-05-26 09:59:11 +09:00
syuilo 564d2446cb wip 2026-05-26 09:48:29 +09:00
syuilo cb605a3978 wip 2026-05-26 09:45:40 +09:00
syuilo 4f89010aca wip 2026-05-26 09:44:05 +09:00
syuilo 8933ac119a wip 2026-05-26 09:38:58 +09:00
syuilo 982897f1b5 wip 2026-05-26 09:36:14 +09:00
syuilo dd69662603 wip 2026-05-26 09:34:55 +09:00
syuilo 9485b3fba9 wip 2026-05-26 09:30:06 +09:00
syuilo 245f673a78 wip 2026-05-26 09:29:48 +09:00
syuilo 71bb388b8b wip: separate package 2026-05-25 22:17:58 +09:00
syuilo 39f2614f9a Update steelRack.schema.ts 2026-05-25 20:27:33 +09:00
syuilo 46efad5b12 wip 2026-05-25 20:26:36 +09:00
syuilo fda845cb63 wip 2026-05-25 18:28:46 +09:00
syuilo cccc66bbbf Create object-schema-defs.ts 2026-05-25 16:28:47 +09:00
syuilo 32822d6d07 mi-objet 2026-05-25 16:25:13 +09:00
syuilo 7bca3ca179 wip 2026-05-25 16:24:07 +09:00
syuilo 6961f277b5 temporary del 2026-05-25 15:57:37 +09:00
syuilo eb33c8a93f wip 2026-05-25 15:51:51 +09:00
syuilo 43232d5baa wip 2026-05-25 15:07:47 +09:00
syuilo b8f67a3e69 wip 2026-05-25 15:07:22 +09:00
syuilo 748b571856 wip i18n separation 2026-05-25 14:58:28 +09:00
syuilo f0bf3cda75 wip i18n separation 2026-05-25 14:33:22 +09:00
syuilo bf28b303d7 clean 2026-05-25 14:01:13 +09:00
syuilo 1a064ea430 wip 2026-05-25 13:51:54 +09:00
syuilo 68431f62f1 Update icosahedron.blend 2026-05-25 13:49:13 +09:00
syuilo 2c8abf8239 icosahedron 2026-05-25 13:48:08 +09:00
syuilo 822b567742 wip 2026-05-25 13:21:14 +09:00
syuilo a89475e820 Update room.add-object-dialog.vue 2026-05-25 13:12:06 +09:00
syuilo 2d8c6ca585 Update room.object-customize-form.vue 2026-05-25 12:48:04 +09:00
syuilo fc6a840132 chair 2026-05-25 12:40:46 +09:00
syuilo 9a44c69177 wip 2026-05-25 12:33:17 +09:00
syuilo 0be946a10b Update ja-JP.yml 2026-05-25 12:17:54 +09:00
syuilo a201f72c8a wip 2026-05-25 12:13:16 +09:00
syuilo 83a4281a30 wip 2026-05-25 11:59:23 +09:00
syuilo 7546bf7dc2 wip 2026-05-25 10:53:47 +09:00
syuilo 2942234778 Update cardboardBox.ts 2026-05-25 10:31:37 +09:00
syuilo 540e20864c wip 2026-05-25 10:21:57 +09:00
syuilo 6ee6894184 Update woodRingsPendantLight.ts 2026-05-25 09:37:48 +09:00
syuilo 2a880164f8 Update woodRingsPendantLight.ts 2026-05-25 09:24:09 +09:00
syuilo c002752d65 Update woodRingsPendantLight.ts 2026-05-25 09:17:44 +09:00
syuilo 05e1a43a1c wip 2026-05-24 05:48:13 +09:00
syuilo ec79d6bee7 Update room.object-customize-form.vue 2026-05-24 05:25:29 +09:00
syuilo a3b6dc0899 wip 2026-05-24 05:09:52 +09:00
syuilo 2fffa94eb2 wip 2026-05-24 04:48:56 +09:00
syuilo 0fade25574 Update object.ts 2026-05-24 04:35:33 +09:00
syuilo aa4fa9c54e wip 2026-05-24 04:33:42 +09:00
syuilo fdba915288 Update object.ts 2026-05-23 20:24:07 +09:00
syuilo 773077470e Update engine.ts 2026-05-23 20:20:51 +09:00
syuilo 3eafdbe57a Update cardboardBox.ts 2026-05-23 20:10:42 +09:00
syuilo 8138fd2c34 Update cardboardBox.ts 2026-05-23 20:10:11 +09:00
syuilo ce72d10491 Update engine.ts 2026-05-23 19:43:36 +09:00
syuilo f8851c0f2e wip 2026-05-23 19:43:10 +09:00
syuilo d3a7dc0ea5 Update engine.ts 2026-05-23 18:15:39 +09:00
syuilo 32bdb77e97 Update room.core.vue 2026-05-23 18:14:21 +09:00
syuilo 8ac3c77ae1 Update object.ts 2026-05-23 18:09:36 +09:00
syuilo 8611d55ffc wip 2026-05-23 17:57:35 +09:00
syuilo 14b648faa6 wip 2026-05-23 17:48:39 +09:00
syuilo c6a755c0a7 wip 2026-05-23 16:53:37 +09:00
syuilo 1154bb5370 wip 2026-05-23 15:54:07 +09:00
syuilo 1701e993cf Update cardboardBox.ts 2026-05-23 13:38:52 +09:00
syuilo 99f998cf60 wip 2026-05-23 13:32:51 +09:00
syuilo 05a8dcf897 wip 2026-05-22 18:59:21 +09:00
syuilo 90ea7902eb Update engine.ts 2026-05-22 17:43:44 +09:00
syuilo 644c16aee6 Update engine.ts 2026-05-22 17:42:29 +09:00
syuilo ce5857b265 Update engine.ts 2026-05-22 17:32:20 +09:00
syuilo fbae160442 Update README.md 2026-05-22 11:15:35 +09:00
syuilo 2daad2223d Update engine.ts 2026-05-22 10:40:50 +09:00
syuilo 6cbae336d0 Update env.ts 2026-05-22 10:39:42 +09:00
syuilo 47f9f38a40 wip 2026-05-22 10:31:40 +09:00
syuilo 73d6764a0b wip 2026-05-21 21:23:05 +09:00
syuilo c85ee681bb Update engine.ts 2026-05-21 21:03:19 +09:00
syuilo 61d5a5620a Update issyoubin.ts 2026-05-21 18:38:04 +09:00
syuilo 2873768cd2 Update engine.ts 2026-05-21 18:08:15 +09:00
syuilo 4c957ecd9b wip 2026-05-21 17:29:37 +09:00
syuilo 8c61fc2be0 refactor 2026-05-21 16:52:44 +09:00
syuilo 57057f56f5 wip 2026-05-21 16:37:54 +09:00
syuilo 985dd72a7f Merge branch 'develop' into room 2026-05-21 16:15:27 +09:00
syuilo dfbe765baa ObjectContainer 2026-05-21 16:15:18 +09:00
syuilo 555fa80709 wip 2026-05-21 14:26:27 +09:00
syuilo 7546e7c800 wip 2026-05-21 14:17:03 +09:00
syuilo ce36de7e4f Update steelRack.ts 2026-05-21 14:09:00 +09:00
syuilo 1cb96731e6 Update steelRack.ts 2026-05-21 14:07:03 +09:00
syuilo 4bf0ba3716 semi-procedual steel-rack 2026-05-21 13:29:55 +09:00
syuilo d2e48b707b wip 2026-05-21 12:04:43 +09:00
syuilo 8e70550c84 wip 2026-05-21 11:52:47 +09:00
syuilo 199c0f533b wip 2026-05-21 10:46:15 +09:00
syuilo ad44cc3446 wip 2026-05-21 09:08:14 +09:00
syuilo 8f73c22df3 Update engine.ts 2026-05-20 19:54:23 +09:00
syuilo 20eb342eff wip 2026-05-20 19:09:41 +09:00
syuilo 5d17b557c1 wip 2026-05-20 15:21:02 +09:00
syuilo 43cb18b3e9 wip 2026-05-19 20:26:33 +09:00
syuilo 75dfcf48b6 Update electronicDisplayBoard.ts 2026-05-19 19:23:45 +09:00
syuilo 9e9ae54c26 Update package.json 2026-05-19 16:08:43 +09:00
syuilo e78694f11b up 2026-05-19 16:04:48 +09:00
syuilo 5397beb965 Merge branch 'develop' into room 2026-05-19 15:56:21 +09:00
syuilo 92bc50cb90 Merge branch 'develop' into room 2026-05-19 15:23:21 +09:00
syuilo 45d8b656cb Update EngineBase.ts 2026-05-19 13:41:08 +09:00
syuilo a5e0c594b2 Update engineControllerBase.ts 2026-05-19 13:15:40 +09:00
syuilo 646b0ca041 wip 2026-05-19 13:15:20 +09:00
syuilo de27cc92dc Update engine.ts 2026-05-18 13:27:48 +09:00
syuilo 09d5502eb6 Update utility.ts 2026-05-18 10:44:05 +09:00
syuilo dc6ac2529f Update utility.ts 2026-05-18 10:43:46 +09:00
syuilo a766609963 wip 2026-05-18 10:24:18 +09:00
syuilo e1aa62d2b7 Update engine.ts 2026-05-18 09:40:07 +09:00
syuilo c62d92d88f Update engine.ts 2026-05-18 07:51:14 +09:00
syuilo 1a456998bc Merge branch 'develop' into room 2026-05-18 07:21:45 +09:00
syuilo 14fe51c0a4 wip 2026-05-16 16:40:43 +09:00
syuilo f203ebe4d5 Update engineControllerBase.ts 2026-05-16 11:36:27 +09:00
syuilo de979d3a98 wip 2026-05-16 11:26:46 +09:00
syuilo 7c2e3adf25 wip 2026-05-16 11:15:27 +09:00
syuilo e24cf73a83 update text 2026-05-16 10:52:43 +09:00
syuilo d3eb3867bd handle lost event 2026-05-16 10:49:25 +09:00
syuilo fcfd3331dd miWorldRoomTemp 2026-05-16 09:05:19 +09:00
syuilo 5aa1f0d562 wip 2026-05-16 07:27:50 +09:00
syuilo 491510a11e refactor 2026-05-16 07:15:27 +09:00
syuilo e0382cdf86 duplicateSelectedObject 2026-05-16 07:12:11 +09:00
syuilo 9ae6a9f426 tweak anim 2026-05-16 07:00:19 +09:00
syuilo a8ca0cf9ed refactor 2026-05-16 07:00:00 +09:00
syuilo 45fc8994f0 Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-05-16 06:56:40 +09:00
syuilo 85ae7c7efc Update engine.ts 2026-05-16 06:56:38 +09:00
syuilo 2867f03f50 Update engineControllerBase.ts 2026-05-15 22:03:01 +09:00
syuilo 1cd75fe5a8 wip 2026-05-15 19:59:41 +09:00
syuilo 7f2286f1fd Update room.core.vue 2026-05-15 19:45:17 +09:00
syuilo 840e2c3744 wip 2026-05-15 18:09:14 +09:00
syuilo af1dbd3a71 Update room.core.vue 2026-05-15 17:47:16 +09:00
syuilo 82cdaeb56e wip 2026-05-15 17:45:20 +09:00
syuilo ea732458ab Update room.core.vue 2026-05-15 17:23:42 +09:00
syuilo b6e92cdef9 Update engine.ts 2026-05-15 17:12:55 +09:00
syuilo 11b699a180 wip 2026-05-15 16:06:52 +09:00
syuilo e90a53a851 wip 2026-05-15 15:05:15 +09:00
syuilo 1469f890dd Update engine.ts 2026-05-15 14:40:37 +09:00
syuilo 6c82e0fef8 Update engine.ts 2026-05-15 14:34:26 +09:00
syuilo 6ee52259c4 lazy create putParticleSystem 2026-05-15 13:23:05 +09:00
syuilo 414b522d0a Update engine.ts 2026-05-15 13:15:54 +09:00
syuilo a95f3c9467 Update engine.ts 2026-05-15 13:06:15 +09:00
syuilo ecb2ae379e Update engine.ts 2026-05-15 12:56:39 +09:00
syuilo 2e34e6f454 wip 2026-05-15 12:52:54 +09:00
syuilo bee415625a Update WorldRoomService.ts 2026-05-15 09:45:52 +09:00
syuilo d0d3aef76c Merge branch 'develop' into room 2026-05-15 09:41:51 +09:00
syuilo 93a46ae2d6 Merge branch 'develop' into room 2026-05-15 09:39:21 +09:00
syuilo 8b5d006248 Update room.core.vue 2026-05-14 21:39:02 +09:00
syuilo bcc965a19c Update room.core.vue 2026-05-14 21:26:24 +09:00
syuilo 2c5ce5bfae Update room.core.vue 2026-05-14 21:25:55 +09:00
syuilo 170980a0a6 Update WorldRoomService.ts 2026-05-14 21:19:35 +09:00
syuilo 216fb56a4b wip 2026-05-14 21:08:50 +09:00
syuilo 8defcd9f9f wip 2026-05-14 19:35:50 +09:00
syuilo d38f04c97f wip 2026-05-14 19:08:36 +09:00
syuilo ecedd71192 Update engine.ts 2026-05-14 18:47:51 +09:00
syuilo b582895ac6 up 2026-05-14 17:45:40 +09:00
syuilo 267dc5fb64 Update object.ts 2026-05-14 17:20:11 +09:00
syuilo ab02a99a0e wip 2026-05-14 17:19:59 +09:00
syuilo 03481322a9 wip 2026-05-14 17:10:28 +09:00
syuilo fb130466ae wip 2026-05-14 15:41:12 +09:00
syuilo 43f20e44c3 Update room.add-object-dialog.vue 2026-05-14 15:04:37 +09:00
syuilo 2966d2a862 Merge branch 'develop' into room 2026-05-14 14:43:34 +09:00
syuilo 8f23991723 Update engineControllerBase.ts 2026-05-14 14:38:59 +09:00
syuilo bfdda83a6d wip 2026-05-14 14:38:00 +09:00
syuilo ddce5ff526 wip 2026-05-14 14:30:14 +09:00
syuilo 8dd62e57e2 Update object.ts 2026-05-14 14:27:45 +09:00
syuilo 0b17663eeb wip 2026-05-14 14:25:17 +09:00
syuilo 41c5e7242d add note 2026-05-14 12:44:52 +09:00
syuilo ef8f593de8 attachments 2026-05-14 12:43:30 +09:00
syuilo 410cc8ac50 Update engineControllerBase.ts 2026-05-13 21:13:26 +09:00
syuilo a905eeef03 Update engine.ts 2026-05-13 19:53:34 +09:00
syuilo ebb74eff92 Update room.add-object-dialog.vue 2026-05-13 19:44:03 +09:00
syuilo b69efeeb79 wip 2026-05-13 19:26:55 +09:00
syuilo 1a57b8177b Update room.add-object-dialog.vue 2026-05-13 18:36:09 +09:00
syuilo 0e300a9795 wip 2026-05-13 18:33:04 +09:00
syuilo 13d35f8124 fix 2026-05-13 18:23:48 +09:00
syuilo b53e4f6742 wip 2026-05-13 15:48:34 +09:00
syuilo 9d102c2a70 wip 2026-05-13 14:09:43 +09:00
syuilo 84f389aebd wip 2026-05-13 12:29:34 +09:00
syuilo 8072dbef8b wip 2026-05-13 10:30:42 +09:00
syuilo 8195f5257f support pinch/zoom 2026-05-12 20:23:20 +09:00
syuilo b8f8ac031c wip 2026-05-11 20:40:21 +09:00
syuilo 57f1adb402 wip 2026-05-11 15:45:16 +09:00
syuilo 48ce2e09ab Update utility.ts 2026-05-11 15:35:04 +09:00
syuilo dca5450340 wip 2026-05-11 15:28:46 +09:00
syuilo 202c9e8f25 wip 2026-05-11 15:07:44 +09:00
syuilo 8e1e69e60e Update engine.ts 2026-05-11 14:40:34 +09:00
syuilo f3d0edf546 wip: sr in edit mode 2026-05-11 13:35:32 +09:00
syuilo 08dfd23c19 Update engine.ts 2026-05-11 11:01:17 +09:00
syuilo b231357ae8 Update engine.ts 2026-05-11 10:30:40 +09:00
syuilo 102cf03213 tweak 2026-05-11 10:19:15 +09:00
syuilo d1da15128e wip 2026-05-11 09:50:27 +09:00
syuilo 0d96f418ab wip 2026-05-11 09:34:36 +09:00
syuilo b879f2f1e3 Update engine.ts 2026-05-09 16:36:59 +09:00
syuilo fb77eb4349 Update previewEngine.ts 2026-05-09 14:41:39 +09:00
syuilo 79ad7e274c Update previewEngine.ts 2026-05-09 14:29:04 +09:00
syuilo 4890673013 Update previewEngine.ts 2026-05-09 14:28:14 +09:00
syuilo 1f204f572f Update room.vue 2026-05-09 14:15:15 +09:00
syuilo 9976588025 Update room.add-object-dialog.vue 2026-05-09 14:13:49 +09:00
syuilo 414a28fb19 wip 2026-05-09 14:06:07 +09:00
syuilo 3605ffdafc wip 2026-05-09 13:52:45 +09:00
syuilo 179c9fc70a Update previewEngine.ts 2026-05-09 13:10:30 +09:00
syuilo f3a7f10319 wip 2026-05-09 13:03:57 +09:00
syuilo 17333fd7e5 Merge branch 'develop' into room 2026-05-08 18:26:18 +09:00
syuilo 14f4d2c228 wip 2026-05-08 18:23:16 +09:00
syuilo 18b4210eef Update room.add-object-dialog.vue 2026-05-08 18:10:59 +09:00
syuilo 54c2c4dd53 wip 2026-05-07 17:38:49 +09:00
syuilo 42da479026 Update tabletopLcdButtonsController.ts 2026-05-07 16:00:49 +09:00
syuilo adc487ef78 wip 2026-05-07 15:17:49 +09:00
syuilo b08b3a2500 Update curtain.blend 2026-05-07 14:56:14 +09:00
syuilo 4324b6def2 wip 2026-05-07 14:17:09 +09:00
syuilo 21fe0f5e67 wip 2026-05-07 12:56:18 +09:00
syuilo 3ede04c563 Merge branch 'develop' into room 2026-05-07 11:43:07 +09:00
syuilo b750d69065 Merge branch 'develop' into room 2026-05-07 11:33:46 +09:00
syuilo 0c4b36e2d1 wip 2026-05-07 10:56:26 +09:00
syuilo 5cb9474494 wip 2026-05-06 19:17:46 +09:00
syuilo 3be075d281 Update room.add-object-dialog.vue 2026-05-06 18:30:57 +09:00
syuilo d7c94fbf86 handheldGameConsole 2026-05-06 18:25:07 +09:00
syuilo c6d7aa7be8 Update room.add-object-dialog.vue 2026-05-06 14:31:55 +09:00
syuilo d56c6dfe57 wip 2026-05-06 14:24:38 +09:00
syuilo f9be5d8c47 wip 2026-05-06 11:49:16 +09:00
syuilo ac023668a7 Update engine.ts 2026-05-06 11:42:02 +09:00
syuilo 59c9b86842 Update engine.ts 2026-05-06 11:39:13 +09:00
syuilo c4adcde114 Update room.vue 2026-05-05 20:29:19 +09:00
syuilo db1b5e9ce9 wip 2026-05-05 20:29:10 +09:00
syuilo a1cb4b8304 Update README.md 2026-05-04 22:37:55 +09:00
syuilo fd04c5f2fc Update README.md 2026-05-04 22:36:57 +09:00
syuilo 7be7465703 wip 2026-05-04 21:41:21 +09:00
syuilo 626ae675bc wip 2026-05-04 21:30:43 +09:00
syuilo 2a1cd5c197 wip 2026-05-04 19:32:47 +09:00
syuilo a9dd5fd5bf wip 2026-05-04 16:41:50 +09:00
syuilo beb5d1dec5 wip 2026-05-04 16:25:11 +09:00
syuilo d451ce8c36 wip 2026-05-04 15:41:44 +09:00
syuilo f0f78a11cb Update woodRingsPendantLight.ts 2026-05-04 10:47:14 +09:00
syuilo 69dd2675fa Update env.ts 2026-05-04 10:05:25 +09:00
syuilo eb7691e3ba wip 2026-05-04 09:38:35 +09:00
syuilo db90e4ebc0 Update env.ts 2026-05-04 09:24:42 +09:00
syuilo 29491997ea wip 2026-05-04 09:22:29 +09:00
syuilo 015e6d1c81 wip 2026-05-04 09:14:58 +09:00
syuilo 49ee15dd9a Update room.vue 2026-05-03 21:35:19 +09:00
syuilo e6b5758d54 wip 2026-05-03 21:34:11 +09:00
syuilo 210368d597 wip 2026-05-03 20:49:03 +09:00
syuilo 203f29afb9 wip 2026-05-03 18:42:04 +09:00
syuilo fa0eac34c2 Update engine.ts 2026-05-03 17:06:17 +09:00
syuilo d4fa5cf7ca Update engine.ts 2026-05-03 17:02:36 +09:00
syuilo 479e9af17e Update engine.ts 2026-05-03 17:01:49 +09:00
syuilo f03af71dc0 wip 2026-05-03 16:54:11 +09:00
syuilo 6d94f00ecf Update env.ts 2026-05-03 16:21:57 +09:00
syuilo 9e848f3135 default -> simple 2026-05-03 16:20:53 +09:00
syuilo b16b158372 Update room.vue 2026-05-03 16:19:12 +09:00
syuilo 39525c66c2 heya -> env 2026-05-03 16:19:03 +09:00
syuilo d3dc9bc86c wip 2026-05-03 16:12:58 +09:00
syuilo 444d862eac wip 2026-05-03 16:00:57 +09:00
syuilo 74e9851511 wip 2026-05-03 15:45:33 +09:00
syuilo a8586fe224 Update wallMountSpotLight.ts 2026-05-03 14:15:08 +09:00
syuilo d132fdfc04 Update ductRailSpotLights.ts 2026-05-03 14:11:31 +09:00
syuilo 3ba902c2b6 Update ductRailSpotLights.ts 2026-05-03 14:02:59 +09:00
syuilo 50f7c74259 Update controller.ts 2026-05-03 12:17:55 +09:00
syuilo 9b7c908c68 Update room.vue 2026-05-03 12:13:33 +09:00
syuilo e7290c0486 rotation 2026-05-03 12:12:45 +09:00
syuilo db22eddd1e Update engine.ts 2026-05-03 11:51:35 +09:00
syuilo a8cea0622d Update room.object-customize-form.vue 2026-05-03 10:53:21 +09:00
syuilo 5bf1b5569f 🎨 2026-05-03 10:50:10 +09:00
syuilo d1cb2c5bc7 Update worker.ts 2026-05-03 10:00:01 +09:00
syuilo a49697042a refactor: use AbortController 2026-05-03 09:40:04 +09:00
syuilo 4656d93358 wip 2026-05-02 21:27:17 +09:00
syuilo 2e5a02a85a Update controller.ts 2026-05-02 20:12:38 +09:00
syuilo 8421ec75da Merge branch 'develop' into room 2026-05-02 12:41:38 +09:00
syuilo dcd2160294 Update ductRailSpotLights.ts 2026-05-01 20:37:47 +09:00
syuilo 3833469955 Update engine.ts 2026-05-01 20:32:50 +09:00
syuilo 0adfbc8d51 Update lavaLamp.ts 2026-05-01 19:46:44 +09:00
syuilo a931079896 Update lavaLamp.ts 2026-05-01 19:37:50 +09:00
syuilo 18c08f52f1 wip 2026-05-01 19:31:36 +09:00
syuilo 8975449538 Update engine.ts 2026-05-01 18:04:25 +09:00
syuilo 4e149a642d wip 2026-05-01 17:53:40 +09:00
syuilo 09d133242d Update engine.ts 2026-05-01 12:19:03 +09:00
syuilo a8db20259b fix wasdVec calculation 2026-05-01 12:04:05 +09:00
syuilo d1eda166de wip 2026-05-01 11:54:52 +09:00
syuilo 7df4b729e9 Update controller.ts 2026-05-01 11:36:30 +09:00
syuilo 3ed6148f6a wip 2026-05-01 10:02:39 +09:00
syuilo ebe5739ce3 recordPlayer 2026-05-01 09:58:13 +09:00
syuilo 9bc404a8f5 wip 2026-04-30 21:49:09 +09:00
syuilo 90ff3d79d1 Update electronicDisplayBoard.ts 2026-04-30 21:20:55 +09:00
syuilo 6fb49ab88d note 2026-04-30 20:55:21 +09:00
syuilo 722d09b1ae wip 2026-04-30 20:52:53 +09:00
syuilo 6d6ae6728c wip 2026-04-30 19:24:10 +09:00
syuilo ce2e74f3ca wip 2026-04-30 18:06:50 +09:00
syuilo b34e957c25 Update electronicDisplayBoard.ts 2026-04-30 17:38:34 +09:00
syuilo 157b4673fd Update tv.ts 2026-04-30 16:48:45 +09:00
syuilo ac1a19e95c Merge branch 'develop' into room 2026-04-30 16:44:38 +09:00
syuilo 5639324077 up 2026-04-30 16:44:20 +09:00
syuilo a471fe16fa wip 2026-04-30 16:34:07 +09:00
syuilo 6cf90fd714 wip 2026-04-30 16:01:11 +09:00
syuilo c7c785ad2a Update heya.ts 2026-04-30 14:05:58 +09:00
syuilo bde64b5b1f Update engine.ts 2026-04-30 13:53:28 +09:00
syuilo 30f7727e33 wip 2026-04-30 13:32:10 +09:00
syuilo 5d36da17fe wip 2026-04-30 12:16:35 +09:00
syuilo 7bcda08339 wip 2026-04-30 11:52:53 +09:00
syuilo a15575078f Update engine.ts 2026-04-30 11:22:05 +09:00
syuilo 56d813a184 Update engine.ts 2026-04-30 11:13:26 +09:00
syuilo 8f5c09daa1 wip 2026-04-30 09:17:22 +09:00
syuilo c2d5a33400 wip 2026-04-30 08:36:23 +09:00
syuilo 6f3f4e7ef1 wip 2026-04-30 08:32:55 +09:00
syuilo 49f21d7423 wip 2026-04-29 21:06:15 +09:00
syuilo 92a6086e21 Create wall-wood.png 2026-04-29 21:03:40 +09:00
syuilo 9d1c2d52d2 Update tabletopLcdButtonsController.ts 2026-04-29 20:57:59 +09:00
syuilo f97a6c6d55 wip 2026-04-29 20:16:23 +09:00
syuilo 0f69a284c6 wip 2026-04-29 20:07:34 +09:00
syuilo 1427d887dd wip 2026-04-29 20:00:56 +09:00
syuilo a4c9aff8a9 wip 2026-04-29 19:38:21 +09:00
syuilo 5db8ccec74 Update worker.ts 2026-04-29 17:34:02 +09:00
syuilo 70ebc0d32c clean 2026-04-29 17:17:42 +09:00
syuilo d885627350 refactor 2026-04-29 17:16:05 +09:00
syuilo 4a77db7866 refactor 2026-04-29 17:04:06 +09:00
syuilo e50d4fa8ab Update engine.ts 2026-04-29 16:58:11 +09:00
syuilo 27578f2688 wip 2026-04-29 15:23:00 +09:00
syuilo fc97ba41af Update README.md 2026-04-29 15:08:43 +09:00
syuilo a3610ae6c4 🎨 2026-04-29 15:05:04 +09:00
syuilo fc615daad3 Update engine.ts 2026-04-29 13:02:56 +09:00
syuilo 0321edb1ac Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-04-29 13:02:31 +09:00
syuilo 8593737886 clean 2026-04-29 13:02:10 +09:00
syuilo 35ad1d758e wip 2026-04-29 12:59:46 +09:00
syuilo 110e5daa6f Update README.md 2026-04-29 12:19:26 +09:00
syuilo 5de191f01a wip 2026-04-29 11:41:14 +09:00
syuilo 80c2b1fa65 mhq 2026-04-29 11:01:42 +09:00
syuilo c8441da835 wip 2026-04-29 10:04:45 +09:00
syuilo fa2b1d6096 wip 2026-04-29 09:53:29 +09:00
syuilo 18a4da4ad7 wip 2026-04-29 09:49:04 +09:00
syuilo 52e9395fab wip 2026-04-29 09:40:12 +09:00
syuilo d05d7938a4 Update engine.ts 2026-04-28 21:12:05 +09:00
syuilo 7846e8efb8 Update engine.ts 2026-04-28 21:08:14 +09:00
syuilo e09f832fad wip 2026-04-28 17:53:48 +09:00
syuilo 515f6d9790 wip 2026-04-28 16:52:24 +09:00
syuilo 657159da45 wip 2026-04-28 16:25:43 +09:00
syuilo e88188cd6d wip 2026-04-28 16:22:08 +09:00
syuilo dcb834ed41 Update controller.ts 2026-04-28 16:11:27 +09:00
syuilo 5a7960d0a9 wip 2026-04-28 16:03:28 +09:00
syuilo 11e55d8fe8 wip 2026-04-28 15:54:41 +09:00
syuilo 836de1bb28 wip 2026-04-28 14:24:20 +09:00
syuilo 58e617af6d wip 2026-04-28 12:20:14 +09:00
syuilo f44d566933 wip@p 2026-04-28 11:10:57 +09:00
syuilo 72fbc4bc9c Update engine.ts 2026-04-28 09:00:10 +09:00
syuilo 746c16aecc Update engine.ts 2026-04-28 08:58:00 +09:00
syuilo 7bef2cd8e0 wip 2026-04-28 08:46:33 +09:00
syuilo aed73eb074 Update room.vue 2026-04-28 08:23:13 +09:00
syuilo a756ca6ffb 🎨 2026-04-28 07:59:23 +09:00
syuilo f19040888a wip 2026-04-27 20:57:30 +09:00
syuilo 634cdf5e1e wip 2026-04-27 19:05:03 +09:00
syuilo c09d445215 wip 2026-04-27 18:28:15 +09:00
syuilo 1d71c0c6dd wip 2026-04-27 17:13:25 +09:00
syuilo 130a43f39a wip 2026-04-27 17:02:43 +09:00
syuilo 9c25c44a8a wip 2026-04-27 16:58:01 +09:00
syuilo 76ce6c84c0 wip 2026-04-27 16:51:05 +09:00
syuilo 24caff71e1 Update engine.ts 2026-04-27 15:22:50 +09:00
syuilo 036b8ea320 wip 2026-04-27 14:46:57 +09:00
syuilo f98394fc60 Update engine.ts 2026-04-27 11:04:46 +09:00
syuilo f52ac6351d wip 2026-04-27 10:58:23 +09:00
syuilo eb1357026d Merge branch 'develop' into room 2026-04-27 10:35:14 +09:00
syuilo 1b5be37f9b Update room.vue 2026-04-27 10:23:56 +09:00
syuilo 42c659c580 Merge branch 'develop' into room 2026-04-27 10:19:37 +09:00
syuilo ec32cad19f Update room.vue 2026-04-27 10:05:02 +09:00
syuilo 0910c47612 Update room.vue 2026-04-27 09:14:36 +09:00
syuilo 88e7303779 wip 2026-04-27 09:02:49 +09:00
syuilo 3a5532211b wip 2026-04-27 08:36:41 +09:00
syuilo 5b945278f9 wip 2026-04-26 21:21:04 +09:00
syuilo 0db2e5a42f Update room.vue 2026-04-26 20:56:47 +09:00
syuilo 8e1c5673b8 Update room.vue 2026-04-26 20:51:27 +09:00
syuilo a763c396bd Update MkProgressBar.vue 2026-04-26 20:51:23 +09:00
syuilo f5dae1d4c8 wip 2026-04-26 18:45:33 +09:00
syuilo 17697ba6ec joystick 2026-04-26 17:08:51 +09:00
syuilo a77987ab28 Update engine.ts 2026-04-26 13:58:33 +09:00
syuilo 47d9e92776 Update engine.ts 2026-04-26 13:25:28 +09:00
syuilo af928ffe93 Update engine.ts 2026-04-26 12:17:43 +09:00
syuilo 4adca586ed wip 2026-04-26 12:11:15 +09:00
syuilo e703705d60 Update engine.ts 2026-04-26 11:33:30 +09:00
syuilo be434949a4 Update engine.ts 2026-04-26 11:26:23 +09:00
syuilo bbff43e9e6 Update room.vue 2026-04-26 11:22:46 +09:00
syuilo 5c28ee0536 Update engine.ts 2026-04-26 10:08:43 +09:00
syuilo 7e41d17c6a Update engine.ts 2026-04-26 10:05:00 +09:00
syuilo 1cd6d01fdd wip 2026-04-25 04:19:52 +09:00
syuilo 3263f4bcc0 Update tabletopLcdButtonsController.ts 2026-04-24 19:40:39 +09:00
syuilo 58feedb53d wip 2026-04-24 19:38:25 +09:00
syuilo e62c85a971 wip 2026-04-24 19:35:19 +09:00
syuilo 585d727297 wip 2026-04-24 19:04:37 +09:00
syuilo abd6c85b41 Update engine.ts 2026-04-24 18:10:43 +09:00
syuilo 9551a3d01a wip 2026-04-24 17:55:13 +09:00
syuilo d281a81200 Update engine.ts 2026-04-24 16:43:04 +09:00
syuilo 82741c2d61 Update engine.ts 2026-04-24 16:23:13 +09:00
syuilo b00880c21f wip 2026-04-24 15:42:58 +09:00
syuilo df5d5d23cc Update engine.ts 2026-04-24 13:51:25 +09:00
syuilo 35f6cac9f6 wip 2026-04-24 13:26:38 +09:00
syuilo 7ddcbf5e94 Update previewEngine.ts 2026-04-24 11:42:49 +09:00
syuilo 634bae3c49 wip 2026-04-24 11:36:35 +09:00
syuilo 0b50aa9d13 Update room.add-object-dialog.vue 2026-04-24 11:27:08 +09:00
syuilo e594ad9c6f Update room.add-object-dialog.vue 2026-04-24 11:19:49 +09:00
syuilo d105c707ec Update room.add-object-dialog.vue 2026-04-24 10:59:42 +09:00
syuilo 93f24c5b8f Update room.add-object-dialog.vue 2026-04-24 10:33:55 +09:00
syuilo 7c170a21e5 wip 2026-04-24 10:16:13 +09:00
syuilo 1de4440dbd ductRailSpotLights 2026-04-23 21:17:12 +09:00
syuilo e6ce36178c wip 2026-04-23 20:22:24 +09:00
syuilo 8a6e925297 wip 2026-04-23 20:18:46 +09:00
syuilo eeda7e7002 note 2026-04-23 18:11:54 +09:00
syuilo abfa67965e wip 2026-04-23 17:48:12 +09:00
syuilo 2c6560cc71 Update previewEngine.ts 2026-04-23 17:28:21 +09:00
syuilo 4984146f6e Update issyoubin.ts 2026-04-23 17:26:40 +09:00
syuilo c36dfc6643 wip 2026-04-23 17:17:09 +09:00
syuilo 9184f0d7b9 issyoubin wip 2026-04-23 16:54:59 +09:00
syuilo 4c659c3129 Update previewEngine.ts 2026-04-23 16:50:54 +09:00
syuilo 10926e5525 Update utility.ts 2026-04-23 15:37:26 +09:00
syuilo 8d3a5a6503 morph 2026-04-23 13:45:50 +09:00
syuilo 924c517bb5 Update randomBooks.ts 2026-04-23 12:31:44 +09:00
syuilo 54c339d89c Update randomBooks.ts 2026-04-23 12:23:34 +09:00
syuilo ce98d4244b Update tabletopDigitalClock.ts 2026-04-23 12:19:54 +09:00
syuilo 4e4b56699b wip 2026-04-23 11:57:53 +09:00
syuilo fab7667b0a 平積み 2026-04-23 09:59:15 +09:00
syuilo 942e32f4de 🎨 2026-04-23 08:19:00 +09:00
syuilo 3897b044fe Update previewEngine.ts 2026-04-22 22:39:42 +09:00
syuilo c47a0c33cf wip 2026-04-22 21:45:33 +09:00
syuilo 2b849685a1 Update engine.ts 2026-04-22 21:24:19 +09:00
syuilo bad29c7604 tableSalt 2026-04-22 21:07:33 +09:00
syuilo b7b24a2140 Update books.ts 2026-04-22 19:45:21 +09:00
syuilo 20d9a03bb3 wip 2026-04-22 19:10:18 +09:00
syuilo 09d58a3ecf wip 2026-04-22 18:33:17 +09:00
syuilo 94e8050455 wallMountSpotLight 2026-04-22 17:28:15 +09:00
syuilo 2f039ce2e9 Update controller.ts 2026-04-22 16:04:38 +09:00
syuilo 61fd35bc97 wip 2026-04-22 16:02:24 +09:00
syuilo 2d36ccf1b2 wip 2026-04-22 14:12:11 +09:00
syuilo 174221fdc4 Update engine.ts 2026-04-22 13:56:38 +09:00
syuilo 08a0f03a45 Update engine.ts 2026-04-22 12:12:58 +09:00
syuilo de795a48e8 Update engine.ts 2026-04-22 12:10:50 +09:00
syuilo af8a0bdf12 Update engine.ts 2026-04-22 11:54:24 +09:00
syuilo b734ab3419 Update engine.ts 2026-04-22 11:33:08 +09:00
syuilo 595e66c423 Update engine.ts 2026-04-22 11:26:53 +09:00
syuilo 7b0e839661 up 2026-04-22 09:36:40 +09:00
syuilo 6f79420fdc 🎨 2026-04-22 09:31:40 +09:00
syuilo b41bad4188 tabletop-lcd-buttons-controller 2026-04-22 09:16:31 +09:00
syuilo 2f34f1c6f1 🎨 2026-04-21 19:42:55 +09:00
syuilo 7b80da7737 🎨 2026-04-21 17:18:46 +09:00
syuilo e65e3b4569 Update previewEngine.ts 2026-04-21 17:09:39 +09:00
syuilo 0ef489513a 🎨 2026-04-21 17:01:32 +09:00
syuilo a92cae8e09 Merge branch 'develop' into room 2026-04-21 16:38:16 +09:00
syuilo 2619509d69 wip 2026-04-21 16:35:13 +09:00
syuilo da8945dc23 stormGlass 2026-04-21 16:30:42 +09:00
syuilo 64995bebc5 glassCylinderPotPlant 2026-04-21 15:54:05 +09:00
syuilo c2dde53c1c gizmo 2026-04-21 15:08:11 +09:00
syuilo efaf7bdd95 side grid snap 2026-04-21 14:28:27 +09:00
syuilo 3dcbfc0168 wall snap 2026-04-21 14:16:37 +09:00
syuilo e96e88b1ce fix scale 2026-04-21 13:33:43 +09:00
syuilo 493a2eb50c Update engine.ts 2026-04-21 13:23:13 +09:00
syuilo fc4d769e1e grid 2026-04-21 12:36:44 +09:00
syuilo 0f0bc9b54f fix scale 2026-04-21 12:18:20 +09:00
syuilo ead90471c4 note 2026-04-21 12:08:01 +09:00
syuilo 461e083454 fix scale 2026-04-21 12:02:58 +09:00
syuilo 79fe0fbd05 make WORLD_SCALE 1 2026-04-21 11:55:53 +09:00
syuilo db9d0090b7 clean up 2026-04-21 11:47:58 +09:00
syuilo 1f81960640 wip 2026-04-21 11:46:48 +09:00
syuilo 5d389732d9 Update randomBooks.ts 2026-04-21 11:27:56 +09:00
syuilo b300f9620c Update woodRingsPendantLight.ts 2026-04-21 11:24:43 +09:00
syuilo ddafd9e517 Update engine.ts 2026-04-21 11:23:18 +09:00
syuilo 2b9f593e03 Update engine.ts 2026-04-21 11:19:48 +09:00
syuilo 5f3f3d715a 9.3.3 2026-04-21 11:08:42 +09:00
syuilo de62fa3b59 Update engine.ts 2026-04-21 09:47:49 +09:00
syuilo 32cba5b979 note 2026-04-21 09:46:12 +09:00
syuilo ab90824b9c wip 2026-04-21 09:44:23 +09:00
syuilo ebec026508 Update engine.ts 2026-04-21 09:12:00 +09:00
syuilo 8ddb2cbe75 Update engine.ts 2026-04-21 09:09:25 +09:00
syuilo 97439d7718 Update engine.ts 2026-04-21 08:53:18 +09:00
syuilo 60a2cd9306 Update engine.ts 2026-04-21 08:52:31 +09:00
syuilo 62be4a258e fix 2026-04-21 07:53:24 +09:00
syuilo ae4a174de5 Merge branch 'develop' into room 2026-04-21 06:13:42 +09:00
syuilo 1e15503000 up 2026-04-21 06:13:17 +09:00
syuilo 61ac82d297 Update petBottle.ts 2026-04-20 21:39:23 +09:00
syuilo f18b3467d9 🎨 2026-04-20 21:37:20 +09:00
syuilo dec440b6cc wip 2026-04-20 21:25:31 +09:00
syuilo 26ac4f7732 note 2026-04-20 20:52:24 +09:00
syuilo b7b3c07a96 Update engine.ts 2026-04-20 20:50:52 +09:00
syuilo 2040827615 wip 2026-04-20 20:47:31 +09:00
syuilo 2b456fec47 wip 2026-04-20 20:21:42 +09:00
syuilo b21ad59db3 wip 2026-04-20 20:19:18 +09:00
syuilo f04799a4f5 wip 2026-04-20 20:07:49 +09:00
syuilo 28dec6b0a3 clean up 2026-04-20 20:01:25 +09:00
syuilo 714bff0835 Update room.add-object-dialog.vue 2026-04-20 20:00:41 +09:00
syuilo 772608ae99 wip 2026-04-20 19:13:29 +09:00
syuilo fe3b3704ba 🎨 2026-04-20 18:10:43 +09:00
syuilo d6caef7ee7 Update heya.ts 2026-04-20 16:29:28 +09:00
syuilo 83a15f74ef boxWallShelf 2026-04-20 16:07:35 +09:00
syuilo 27addb49cf wip 2026-04-20 14:38:12 +09:00
syuilo 6aa741f8d4 wip 2026-04-20 14:32:46 +09:00
syuilo e224bdf5e4 Update previewEngine.ts 2026-04-20 12:41:11 +09:00
syuilo 9fe2044f53 Update previewEngine.ts 2026-04-20 12:33:25 +09:00
syuilo b1aef2d308 Update previewEngine.ts 2026-04-20 12:29:08 +09:00
syuilo 79a063f692 wip 2026-04-20 12:17:57 +09:00
syuilo c3c36b06c2 Update previewEngine.ts 2026-04-20 11:36:39 +09:00
syuilo 358f0c0a6f Update engine.ts 2026-04-20 09:58:18 +09:00
syuilo 6041db87e8 wip 2026-04-20 09:46:10 +09:00
syuilo ff4af812b5 wip 2026-04-20 09:14:22 +09:00
syuilo c2428ca3cc fix 2026-04-19 20:23:07 +09:00
syuilo e402057d3b timer 2026-04-19 20:15:51 +09:00
syuilo 3811de2283 Update utility.ts 2026-04-19 20:07:05 +09:00
syuilo 421d466921 speakerStand 2026-04-19 17:36:24 +09:00
syuilo a211d0df51 wip 2026-04-19 17:11:59 +09:00
syuilo c0690c9b80 wip 2026-04-19 16:46:55 +09:00
syuilo 8b9164a8c3 wip 2026-04-19 16:38:42 +09:00
syuilo 5df01bae9a wip 2026-04-19 13:37:43 +09:00
syuilo f8e093466d fixes 2026-04-19 13:32:08 +09:00
syuilo 664ca528fe wip 2026-04-19 13:21:21 +09:00
syuilo aaab1e7260 wip 2026-04-19 09:52:02 +09:00
syuilo a85f05ca29 wip 2026-04-18 20:58:52 +09:00
syuilo 3253d30073 wip 2026-04-18 20:30:53 +09:00
syuilo 02d365e27b wip 2026-04-18 20:07:50 +09:00
syuilo a4be9b2078 wip 2026-04-18 18:08:37 +09:00
syuilo 47cd092380 wip 2026-04-18 15:08:36 +09:00
syuilo d1555d5423 Update engine.ts 2026-04-18 14:08:35 +09:00
syuilo acd9b94b49 wip 2026-04-18 14:07:14 +09:00
syuilo 4b135bccd8 wip 2026-04-18 11:54:03 +09:00
syuilo 109fdd2ff3 Merge branch 'develop' into room 2026-04-18 08:17:59 +09:00
syuilo aa5275137e wip 2026-04-18 08:13:37 +09:00
syuilo 669286c1d8 wip 2026-04-17 20:42:02 +09:00
syuilo 623b4f087f wip 2026-04-17 17:53:11 +09:00
syuilo 02c6e1b876 fix 2026-04-17 15:39:12 +09:00
syuilo 6cca5706f7 wip 2026-04-17 15:31:56 +09:00
syuilo ebdf627b19 update directory structure 2026-04-17 10:16:05 +09:00
syuilo ae463cde5e wip 2026-04-17 10:03:33 +09:00
syuilo 6350890e9f Update engine.ts 2026-04-16 19:04:13 +09:00
syuilo 77fc803612 Update engine.ts 2026-04-16 18:52:47 +09:00
syuilo e87752a07c clean up 2026-04-16 18:06:59 +09:00
syuilo a7c8a3d6d1 Update engine.ts 2026-04-16 17:34:35 +09:00
syuilo 2175a3a18f Update engine.ts 2026-04-16 17:20:49 +09:00
syuilo 3d48146b92 Update engine.ts 2026-04-16 15:40:48 +09:00
syuilo 4f9aded205 Update engine.ts 2026-04-16 15:27:23 +09:00
syuilo e634fb1456 wip 2026-04-16 14:50:10 +09:00
syuilo 2b0d0d4533 Merge branch 'develop' into room 2026-04-16 14:34:24 +09:00
syuilo 4ac9da7f1f Merge branch 'develop' into room 2026-04-16 12:47:24 +09:00
syuilo 08e0ecf99b Update engine.ts 2026-04-16 11:01:32 +09:00
syuilo 007a2481ef Update ceilingFanLight.ts 2026-04-16 10:58:12 +09:00
syuilo 61eea5799b 🎨 2026-04-16 10:57:58 +09:00
syuilo 1fe9117944 miObjet 2026-04-16 10:41:24 +09:00
syuilo 4a16a71fa2 note 2026-04-16 09:53:49 +09:00
syuilo 85701fcb6d Update README.md 2026-04-16 09:50:06 +09:00
syuilo cd918817d9 Update README.md 2026-04-16 09:48:02 +09:00
syuilo 24c0504cb0 🎨 2026-04-16 08:05:39 +09:00
syuilo 750a48df62 todo 2026-04-16 08:00:48 +09:00
syuilo ead79ab275 wip 2026-04-16 07:59:33 +09:00
syuilo 124079f80a wip 2026-04-15 21:44:26 +09:00
syuilo ac2c6b93ce 🎨 2026-04-15 19:48:52 +09:00
syuilo b0d4ab371b wip 2026-04-15 19:08:08 +09:00
syuilo a3c3f7ba91 clean uv 2026-04-15 18:57:37 +09:00
syuilo 12e1b86b53 note 2026-04-15 18:43:22 +09:00
syuilo 7fd9ac1cc8 wip 2026-04-15 18:30:13 +09:00
syuilo da8a7abcde setRenderingAutoClearDepthStencil 2026-04-15 18:24:15 +09:00
syuilo 6275196101 progress 2026-04-15 18:10:42 +09:00
syuilo 7eb8723082 Update engine.ts 2026-04-15 17:48:49 +09:00
syuilo d730c26fcc Update engine.ts 2026-04-15 17:44:33 +09:00
syuilo 56c2e1c989 Update engine.ts 2026-04-15 16:50:12 +09:00
syuilo 77acce78dd wip 2026-04-15 16:31:32 +09:00
syuilo db5f64b097 wip 2026-04-15 16:18:15 +09:00
syuilo ddfd9f46f3 Update engine.ts 2026-04-15 13:11:47 +09:00
syuilo 4254268f8d TransformNode 2026-04-15 12:14:27 +09:00
syuilo 5104bafe95 hasCollisions 2026-04-15 11:54:38 +09:00
syuilo c426f95bee wip 2026-04-15 11:36:26 +09:00
syuilo c9ae842258 optimize collisions 2026-04-15 11:18:20 +09:00
syuilo 6a7e05d00a Update room.vue 2026-04-15 10:45:50 +09:00
syuilo af5bdb4296 cm 2026-04-15 09:23:11 +09:00
syuilo cf46a4af1e scale intensity 2026-04-15 09:17:11 +09:00
syuilo 83daf43f49 scale intensity 2026-04-15 09:01:59 +09:00
syuilo 3db0b8a1fd cm 2026-04-15 08:51:47 +09:00
syuilo cf9349f29c controller 2026-04-14 21:30:07 +09:00
syuilo 7e0b5ff8be fps 2026-04-14 15:50:06 +09:00
syuilo 28030ea3fa Update engine.ts 2026-04-14 12:53:29 +09:00
syuilo 357aeff407 Update dj-player.glb 2026-04-14 12:45:05 +09:00
syuilo 2f80442b99 🎨 2026-04-14 12:40:33 +09:00
syuilo 3bad686c71 Update engine.ts 2026-04-14 12:26:15 +09:00
syuilo 240c055b45 collision 2026-04-14 10:43:40 +09:00
syuilo 02eb8cfe1c Update room.vue 2026-04-14 08:03:15 +09:00
syuilo 87e2a046b7 🎨 2026-04-14 05:43:42 +09:00
syuilo 4ce42a02c1 newtonsCradle 2026-04-14 04:56:55 +09:00
syuilo f61b2504cc Update engine.ts 2026-04-14 04:26:22 +09:00
syuilo 1b119c49a1 update 2026-04-14 04:15:17 +09:00
syuilo 0ae3eb0721 Update engine.ts 2026-04-14 03:26:34 +09:00
syuilo 205d2c3343 wip 2026-04-13 21:42:12 +09:00
syuilo b6e269d140 fix 2026-04-13 21:10:09 +09:00
syuilo b9335bc314 🎨 2026-04-13 20:21:09 +09:00
syuilo ae92f75345 fix 2026-04-13 18:39:09 +09:00
syuilo 2ff307fe42 Update ironFrameTable.ts 2026-04-13 18:18:26 +09:00
syuilo 60bdd30681 Update tabletopIronFrameStand.ts 2026-04-13 18:11:41 +09:00
syuilo c877210828 ironFrameTable 2026-04-13 18:08:39 +09:00
syuilo e85d1c6139 tabletopIronFrameStand 2026-04-13 17:29:07 +09:00
syuilo 2094e82a30 desk 2026-04-13 16:41:21 +09:00
syuilo f86c5fb3b2 Update engine.ts 2026-04-13 15:30:31 +09:00
syuilo 6b95d07930 woodRingsPendantLight 2026-04-13 13:14:46 +09:00
syuilo 886f64055d wallGlassPictureFrame 2026-04-13 11:58:08 +09:00
syuilo 4740c76128 wip 2026-04-13 11:18:53 +09:00
syuilo baad4ae929 variable iron-frame-shelf 2026-04-13 11:11:14 +09:00
syuilo e989c4b1a5 cuboid 2026-04-13 10:12:24 +09:00
syuilo 8f133d3fed wip 2026-04-13 09:58:02 +09:00
syuilo f26ebef565 pizza 2026-04-12 21:38:39 +09:00
syuilo 7f46bd4928 rename 2026-04-12 21:30:47 +09:00
syuilo abe22da9ed Update ironWoodShelf.ts 2026-04-12 21:28:17 +09:00
syuilo ebc9a60237 ironWoodShelf 2026-04-12 21:22:04 +09:00
syuilo 272c267ea0 Update engine.ts 2026-04-12 19:58:40 +09:00
syuilo 21e6ac6678 🎨 2026-04-12 18:57:46 +09:00
syuilo 2685e254f1 twistedCubeObjet 2026-04-12 17:56:01 +09:00
syuilo 7d5c2052eb Update tabletopGlassPictureFrame.ts 2026-04-12 16:43:19 +09:00
syuilo e8d43032cc wip 2026-04-12 16:41:48 +09:00
syuilo 4ad5234325 tabletopGlassPictureFrame 2026-04-12 15:40:03 +09:00
syuilo 6582087b2f 🎨 2026-04-12 14:30:59 +09:00
syuilo dc689a8c22 🎨 2026-04-12 11:29:39 +09:00
syuilo fa8bdf55be sprayer 2026-04-12 11:29:30 +09:00
syuilo 87828dc0ad fix 2026-04-11 19:37:21 +09:00
syuilo 9fe161ec7c wip 2026-04-11 18:28:31 +09:00
syuilo 938dc5ce40 wip 2026-04-11 15:35:01 +09:00
syuilo 5049857e81 Update randomBooks.ts 2026-04-11 14:19:22 +09:00
syuilo 58cf1414fc Update texture.png 2026-04-11 13:19:04 +09:00
syuilo 6563eb9b8f Update randomBooks.ts 2026-04-11 13:15:07 +09:00
syuilo 69ac19d018 wip 2026-04-11 13:03:11 +09:00
syuilo eeae06014a Update randomBooks.ts 2026-04-11 11:35:33 +09:00
syuilo d808d20f8e wip 2026-04-11 11:19:22 +09:00
syuilo d775fa6360 wip 2026-04-11 05:54:20 +09:00
syuilo 5f4914e6dc Update engine.ts 2026-04-11 05:14:21 +09:00
syuilo 8427685f7b Update engine.ts 2026-04-11 05:08:42 +09:00
syuilo 7dd05a3d60 anim 2026-04-11 05:07:41 +09:00
syuilo 55747bdb99 Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-04-11 05:05:40 +09:00
syuilo 909c0ae156 Update engine.ts 2026-04-11 05:05:30 +09:00
syuilo 4c0b78a1ac add put anim 2026-04-11 05:04:58 +09:00
syuilo 62a9795685 note 2026-04-10 20:38:56 +09:00
syuilo 04ff23c44f dj 2026-04-10 19:55:13 +09:00
syuilo ed119dfeb8 Update engine.ts 2026-04-10 16:10:22 +09:00
syuilo c341ad21db used-tissue 2026-04-10 15:00:26 +09:00
syuilo 11119ab046 book 2026-04-10 14:35:17 +09:00
syuilo dc59fd4edb icosahedron 2026-04-10 12:53:51 +09:00
syuilo 887f548985 wallMirror 2026-04-10 11:41:07 +09:00
syuilo a627b58e85 wallCanvas 2026-04-10 11:06:39 +09:00
syuilo 57fbebaea5 Update engine.ts 2026-04-10 09:59:49 +09:00
syuilo 6386ebe18e wip 2026-04-10 09:46:18 +09:00
syuilo 60bf7c6b1f wip 2026-04-09 22:16:33 +09:00
syuilo 6fe0e97ac6 🍮 2026-04-09 15:43:45 +09:00
syuilo bf72fb0d9a Merge branch 'develop' into room 2026-04-09 14:31:26 +09:00
syuilo 85af12d35a Update engine.ts 2026-04-09 11:29:20 +09:00
syuilo 7970fed6e1 Update engine.ts 2026-04-09 11:23:46 +09:00
syuilo 03c0b48e1e Update engine.ts 2026-04-09 11:16:43 +09:00
syuilo 45c851bb5b wip 2026-04-09 08:57:31 +09:00
syuilo 9b3424c5d3 wip 2026-04-08 22:21:42 +09:00
syuilo 9d9b47daca wip 2026-04-08 20:47:02 +09:00
syuilo 1d8f03e199 wip 2026-04-08 20:34:35 +09:00
syuilo 5e1c0d1064 wip 2026-04-08 18:40:20 +09:00
syuilo a6b7150371 Update engine.ts 2026-04-08 18:08:06 +09:00
syuilo 7701bca55b wip 2026-04-08 16:59:32 +09:00
syuilo 547da71743 refactor 2026-04-08 15:34:42 +09:00
syuilo 25ed41ba64 refactor 2026-04-08 15:24:17 +09:00
syuilo 5e4d128f68 refactor 2026-04-08 15:12:46 +09:00
syuilo 8ec7da0cf4 wip 2026-04-08 14:38:43 +09:00
syuilo 970efb3440 wip 2026-04-08 13:20:09 +09:00
syuilo 58acbac7ef wip 2026-04-08 12:49:32 +09:00
syuilo 5736b43149 wip 2026-04-08 12:40:40 +09:00
syuilo 682c5417cb wip 2026-04-08 12:31:34 +09:00
syuilo 127600a5d4 Update engine.ts 2026-04-08 10:48:44 +09:00
syuilo 61386f699e Update engine.ts 2026-04-08 10:29:16 +09:00
syuilo 3283ec410a Update engine.ts 2026-04-08 10:19:18 +09:00
syuilo 6fd8c8c908 note 2026-04-08 10:16:27 +09:00
syuilo 5b6fa78748 Update engine.ts 2026-04-08 10:06:44 +09:00
syuilo 64fd4b7c0a wip 2026-04-08 10:06:36 +09:00
syuilo 3356cf36d3 note 2026-04-07 21:44:58 +09:00
syuilo e2e4e83e6f note 2026-04-07 21:40:10 +09:00
syuilo 8030e4f3fa note 2026-04-07 21:37:13 +09:00
syuilo a00d80e30c add note 2026-04-07 21:32:37 +09:00
syuilo 82373f24d9 Update engine.ts 2026-04-07 20:16:59 +09:00
syuilo fb0c089a16 wip 2026-04-07 19:18:55 +09:00
syuilo c04c6502be wip 2026-04-07 19:00:25 +09:00
syuilo 6b4310c91d Update engine.ts 2026-04-07 17:11:07 +09:00
syuilo 662de635aa Update woodRingFloorLamp.ts 2026-04-07 17:02:38 +09:00
syuilo af8d14a139 Update engine.ts 2026-04-07 15:45:36 +09:00
syuilo afd731797e wip 2026-04-07 15:43:06 +09:00
syuilo d51d1191c5 apply scale 2026-04-07 09:03:55 +09:00
syuilo f4a060b9a8 Update woodRingFloorLamp.ts 2026-04-06 21:41:28 +09:00
syuilo f6677aa02c wip 2026-04-06 21:28:16 +09:00
syuilo 0d966d8ded wip 2026-04-06 21:14:50 +09:00
syuilo a54a8a10ad Update engine.ts 2026-04-06 15:35:21 +09:00
syuilo 602c8d8be1 Update engine.ts 2026-04-06 14:14:24 +09:00
syuilo bdbbe83421 Update engine.ts 2026-04-06 13:39:36 +09:00
syuilo 50575a272c Update engine.ts 2026-04-06 13:17:47 +09:00
syuilo 3b80a96412 Update engine.ts 2026-04-06 12:55:39 +09:00
syuilo fa979f1fed Update engine.ts 2026-04-06 11:40:53 +09:00
syuilo 38bf5fc0bf 🎨 2026-04-06 08:56:38 +09:00
syuilo 78911b24de wip 2026-04-05 19:54:52 +09:00
syuilo 83b5305671 Update engine.ts 2026-04-05 17:18:58 +09:00
syuilo 07d57b0edd wip 2026-04-05 17:15:19 +09:00
syuilo bd0e6f3268 wip 2026-04-05 15:57:47 +09:00
syuilo 0c808aa23d add note 2026-04-05 15:36:44 +09:00
syuilo 68eebb9d76 wip 2026-04-05 15:31:12 +09:00
syuilo b318cff137 Update engine.ts 2026-04-05 14:49:14 +09:00
syuilo 614e8a7254 Update engine.ts 2026-04-05 13:12:23 +09:00
syuilo 93f62f6054 pc 2026-04-05 13:07:30 +09:00
syuilo ca49618fdb Update engine.ts 2026-04-04 21:54:26 +09:00
syuilo fdd360f490 Update engine.ts 2026-04-04 21:25:13 +09:00
syuilo df5df25c80 wip 2026-04-04 20:55:48 +09:00
syuilo c454b8922b wip 2026-04-04 20:23:24 +09:00
syuilo ba4d495b42 Update engine.ts 2026-04-04 19:19:33 +09:00
syuilo 4ed1d56f03 Update ceilingFanLight.ts 2026-04-04 18:14:45 +09:00
syuilo 49823f9ec3 Update engine.ts 2026-04-04 17:18:55 +09:00
syuilo 85dea8b49d wip 2026-04-04 17:09:20 +09:00
syuilo f2be8a2169 Update engine.ts 2026-04-04 16:57:52 +09:00
syuilo 7121cd1ea9 Update engine.ts 2026-04-04 16:43:32 +09:00
syuilo e79532e50a add note 2026-04-04 16:20:24 +09:00
syuilo 17f97bab7b wip 2026-04-04 16:01:20 +09:00
syuilo f13ada97a7 Update books.ts 2026-04-04 14:55:01 +09:00
syuilo 0db754bdd6 Update engine.ts 2026-04-04 13:47:17 +09:00
syuilo 74dfbb7a74 Update engine.ts 2026-04-04 12:55:05 +09:00
syuilo 2a583509ab Update engine.ts 2026-04-04 12:42:27 +09:00
syuilo 4813ea5afc wip 2026-04-03 20:51:40 +09:00
syuilo 65b6821d4f Update engine.ts 2026-04-03 20:46:22 +09:00
syuilo 8a165321b7 Update engine.ts 2026-04-03 19:52:04 +09:00
syuilo b89fc36cd0 Update engine.ts 2026-04-03 19:49:15 +09:00
syuilo f5ebbbca50 Update engine.ts 2026-04-03 19:23:47 +09:00
syuilo 81414a18d3 wip 2026-04-03 19:16:24 +09:00
syuilo 6bf4feaef5 Update engine.ts 2026-04-03 18:55:50 +09:00
syuilo 4efe4fb519 wip 2026-04-03 18:00:05 +09:00
syuilo 3873eb0cd7 wip 2026-04-03 17:21:21 +09:00
syuilo df092dd120 wip 2026-04-03 16:13:42 +09:00
syuilo 654c2c5b05 Update blind.ts 2026-04-03 14:07:44 +09:00
syuilo f367bc37f8 wip 2026-04-03 13:45:43 +09:00
syuilo e8d8242c09 wip 2026-04-03 12:42:35 +09:00
syuilo 2ecaccedab Update laptopPc.ts 2026-04-03 11:26:15 +09:00
syuilo 4a7614f903 wip 2026-04-03 11:25:25 +09:00
syuilo d3da77c307 wip 2026-04-03 11:18:23 +09:00
syuilo 1c610ce825 Update engine.ts 2026-04-03 10:23:55 +09:00
syuilo d5a5b04468 wip 2026-04-03 10:13:43 +09:00
syuilo b91409f5c6 wip 2026-04-02 21:30:37 +09:00
syuilo 74c67b843e Update engine.ts 2026-04-02 17:51:10 +09:00
syuilo 913e35442c wip 2026-04-02 17:06:09 +09:00
syuilo 7a939dc5b3 Update engine.ts 2026-04-02 14:45:42 +09:00
syuilo 71179b8b24 Update engine.ts 2026-04-02 14:35:00 +09:00
syuilo c5a9c08814 wip 2026-04-02 14:31:20 +09:00
syuilo ea8df304c9 wip 2026-04-02 14:15:52 +09:00
syuilo a01bbf828d Merge branch 'develop' into room 2026-04-02 12:53:16 +09:00
syuilo d2807e974c Update utility.ts 2026-03-31 20:47:42 +09:00
syuilo d6f41f9c51 Update engine.ts 2026-03-31 20:47:16 +09:00
syuilo b2f2e9e75d wip 2026-03-27 19:03:11 +09:00
syuilo ee55a0a6cc Update engine.ts 2026-03-27 18:44:12 +09:00
syuilo df184c8fdf Update engine.ts 2026-03-27 15:48:53 +09:00
syuilo 4dd3bfc208 wip 2026-03-27 15:12:30 +09:00
syuilo c677dd6566 ✌️ 2026-03-27 14:46:53 +09:00
syuilo de53f475c5 Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-03-27 14:20:02 +09:00
syuilo b2eed6b82a wip 2026-03-27 14:19:59 +09:00
syuilo 6eefe6899c Update engine.ts 2026-03-27 13:47:46 +09:00
syuilo 8619d0156e wip 2026-03-27 10:55:33 +09:00
syuilo 9bc0d7b361 wip 2026-03-26 22:20:34 +09:00
syuilo 9ffd8d777e Update engine.ts 2026-03-26 22:13:06 +09:00
syuilo c558f93a0e wip 2026-03-26 21:56:05 +09:00
syuilo 5458ee016d Update engine.ts 2026-03-26 21:13:29 +09:00
syuilo 5886260e0b wip 2026-03-26 21:09:24 +09:00
syuilo 42c7a483a4 wip 2026-03-26 20:27:10 +09:00
syuilo 7f5858a66f wip 2026-03-26 20:26:27 +09:00
syuilo 4965429069 wip 2026-03-26 19:13:45 +09:00
syuilo 58ec8af8cd wip 2026-03-26 14:34:22 +09:00
syuilo 1af8584aca Merge branch 'develop' into room 2026-03-26 14:19:36 +09:00
syuilo 564098a631 Merge branch 'develop' into room 2026-03-22 18:14:44 +09:00
syuilo 6fceb31c44 Update package.json 2026-03-15 16:38:41 +09:00
syuilo 2a1a27f8c7 Merge branch 'develop' into room 2026-03-15 16:38:33 +09:00
syuilo 0e5c8496ce wip 2026-03-07 20:42:43 +09:00
syuilo 7ff95b8f8a wip 2026-03-06 14:09:32 +09:00
syuilo 295c91c245 wip 2026-03-05 16:55:03 +09:00
syuilo 7322697707 Update engine.ts 2026-03-05 12:05:44 +09:00
syuilo ed4e3a51fd wip 2026-03-05 12:00:14 +09:00
syuilo bdc34305be wip 2026-03-05 11:54:25 +09:00
syuilo 354504b534 wip 2026-03-04 18:01:00 +09:00
syuilo f311105b54 wip 2026-03-04 14:22:40 +09:00
syuilo 2984b0000b wip 2026-03-04 13:00:10 +09:00
syuilo 98aadf8dcc wip 2026-03-04 12:20:11 +09:00
syuilo 1d3ddd279b Update pictureFrame.ts 2026-03-04 11:58:54 +09:00
syuilo 349ee141bb wip 2026-03-04 11:57:40 +09:00
syuilo 9070159db7 wip 2026-03-04 11:52:19 +09:00
syuilo 0a4b81b0cc Update pictureFrame.ts 2026-03-04 11:35:46 +09:00
syuilo 2de50f893f wip 2026-03-04 11:33:18 +09:00
syuilo 0157dd6b41 wip 2026-03-04 09:59:06 +09:00
syuilo 208c300460 refactor 2026-03-03 21:22:56 +09:00
syuilo 46791a3bf2 wip 2026-03-03 21:09:59 +09:00
syuilo 16b54e9615 wip 2026-03-03 21:01:31 +09:00
syuilo 78b689f41c wip 2026-03-03 20:27:26 +09:00
syuilo 7b04d5d434 Update engine.ts 2026-03-03 19:30:50 +09:00
syuilo c22345e3e0 wip 2026-03-03 19:10:13 +09:00
syuilo a90c179998 wip 2026-03-03 18:29:53 +09:00
syuilo 8eebeab692 Update poster.ts 2026-03-03 16:48:21 +09:00
syuilo 66e0eeedfb Update poster.ts 2026-03-03 16:34:05 +09:00
syuilo 3874f7abe9 Update poster.ts 2026-03-03 16:33:57 +09:00
syuilo 98f74b0c7a wip 2026-03-03 16:09:51 +09:00
syuilo dbffa5520c wip 2026-03-03 15:51:58 +09:00
syuilo e336cbad62 Update pictureFrame.ts 2026-03-03 14:56:42 +09:00
syuilo 2fab946b7a Update pictureFrame.ts 2026-03-03 14:52:16 +09:00
syuilo 239df4694c Update pictureFrame.ts 2026-03-03 13:43:31 +09:00
syuilo 1c9a324f3a Update engine.ts 2026-03-03 13:26:57 +09:00
syuilo dbdb7ec324 Update pictureFrame.ts 2026-03-03 13:01:57 +09:00
syuilo d43b3be6f0 wip 2026-03-03 12:46:55 +09:00
syuilo 34b46baaea wip 2026-03-03 12:34:52 +09:00
syuilo dc8dda3aac wip 2026-03-03 10:47:36 +09:00
syuilo 1b2717e256 wip 2026-03-02 21:14:25 +09:00
syuilo 9d723aaaa6 wip 2026-03-02 19:18:27 +09:00
syuilo 78bfcd71af wip 2026-03-02 19:05:35 +09:00
syuilo 5175a1e193 wip 2026-03-02 18:08:53 +09:00
syuilo 2438447ad3 wip 2026-03-02 15:26:50 +09:00
syuilo 01385575fd wip 2026-03-02 13:00:43 +09:00
syuilo 3f0c4b0577 wip 2026-03-02 10:46:16 +09:00
syuilo 36438e85d1 wip 2026-03-01 21:57:53 +09:00
syuilo 545009078a wip 2026-03-01 21:36:12 +09:00
syuilo 5910ec68e3 wip 2026-02-27 21:57:43 +09:00
syuilo 3e7166bd2c wip 2026-02-27 15:01:36 +09:00
syuilo dbfd1a751c wip 2026-02-27 13:56:29 +09:00
syuilo dda26f7f48 wip 2026-02-26 21:11:35 +09:00
syuilo 54b30d1138 wip 2026-02-26 15:57:27 +09:00
syuilo d8dc66781f wip 2026-02-26 15:48:59 +09:00
syuilo 4d37ada54d wip 2026-02-26 14:24:58 +09:00
syuilo a8456a45ab wip 2026-02-25 19:15:26 +09:00
syuilo 08667b4d35 wip 2026-02-25 19:05:50 +09:00
syuilo 03f20814c9 wip 2026-02-25 16:50:57 +09:00
syuilo 2672ae4463 Merge branch 'develop' into room 2026-02-24 17:58:15 +09:00
syuilo de4c1b3b66 wip 2026-02-22 21:25:36 +09:00
syuilo ec82773ff7 wip 2026-02-22 18:23:32 +09:00
syuilo cdb8d86fbf wip 2026-02-22 14:24:57 +09:00
syuilo 20dc48f221 wip 2026-02-22 13:55:08 +09:00
syuilo 0729e209c5 wip 2026-02-22 13:25:23 +09:00
syuilo c5eaf0f7af wip 2026-02-22 09:08:50 +09:00
syuilo dcae3ccaaa wip 2026-02-21 21:44:56 +09:00
syuilo fcc36759f7 Update tabletopDigitalClock.ts 2026-02-21 20:45:44 +09:00
syuilo 689c24c776 wip 2026-02-21 20:37:27 +09:00
syuilo 055121d698 Update engine.ts 2026-02-21 18:17:43 +09:00
syuilo 402dd538bf wip 2026-02-21 18:09:37 +09:00
syuilo 8bdf773a2b wip 2026-02-21 15:18:31 +09:00
syuilo 460f79d5cf wip 2026-02-20 21:02:28 +09:00
syuilo 998f85b260 wip 2026-02-20 17:56:29 +09:00
syuilo a0356d8d4d wip 2026-02-20 16:59:40 +09:00
syuilo d68655f5c2 wip 2026-02-20 16:43:12 +09:00
syuilo bba7076eca wip 2026-02-20 16:33:35 +09:00
syuilo aae03a914d wip 2026-02-20 13:15:34 +09:00
syuilo cdc9b47b78 wip 2026-02-20 11:39:42 +09:00
syuilo 41d40f53cf wip 2026-02-19 21:46:53 +09:00
syuilo 17a3bdb5eb wip 2026-02-19 21:38:44 +09:00
syuilo dadc5295fa wip 2026-02-19 19:44:08 +09:00
syuilo 679c75006a Update room.vue 2026-02-19 19:35:19 +09:00
syuilo cd9612e664 wip 2026-02-19 19:21:18 +09:00
syuilo d01b3036d6 wip 2026-02-19 19:09:00 +09:00
syuilo 376bb328df wip 2026-02-19 19:05:26 +09:00
syuilo 6a08231591 wip 2026-02-19 17:23:33 +09:00
syuilo 411c4ef3ae wip 2026-02-17 20:40:18 +09:00
syuilo 6f32e09db5 wip 2026-02-17 20:06:58 +09:00
syuilo 86f6498ddd wip 2026-02-17 16:45:54 +09:00
syuilo 5619cbb0da wip 2026-02-17 16:27:47 +09:00
syuilo 9475e6151f wip 2026-02-17 16:00:31 +09:00
syuilo af560802b3 wip 2026-02-17 13:46:58 +09:00
syuilo 3375220aee wip 2026-02-17 13:34:40 +09:00
syuilo ce7af6a308 wip 2026-02-17 13:27:25 +09:00
syuilo d446e00964 wip 2026-02-17 13:11:06 +09:00
syuilo 90fa65c96e wip 2026-02-17 12:09:07 +09:00
syuilo 3717962757 wip 2026-02-17 11:56:52 +09:00
syuilo 8bd2003a38 wip 2026-02-17 08:46:51 +09:00
syuilo 07909ab228 wip 2026-02-17 08:21:42 +09:00
syuilo 503a02ac42 Update engine.ts 2026-02-17 07:47:37 +09:00
syuilo 8a0ba3a18a Update engine.ts 2026-02-17 07:34:12 +09:00
syuilo 7b7767942f wip 2026-02-17 07:17:27 +09:00
syuilo 8c28c7c253 Update engine.ts 2026-02-16 21:11:13 +09:00
syuilo 0d8a6e8136 Update engine.ts 2026-02-16 21:06:39 +09:00
syuilo 6a4a09c8cf wip 2026-02-16 21:04:32 +09:00
syuilo 06adb3e045 wip 2026-02-16 20:51:48 +09:00
syuilo c12f330432 wip 2026-02-16 18:25:58 +09:00
syuilo a45611171a wip 2026-02-16 16:21:48 +09:00
syuilo f58de15d45 wip 2026-02-16 15:14:37 +09:00
syuilo aa6c9be133 Update engine.ts 2026-02-16 13:57:51 +09:00
syuilo 2841f67166 Update engine.ts 2026-02-16 12:23:06 +09:00
syuilo b1bb07542a Update engine.ts 2026-02-16 12:14:54 +09:00
syuilo eb0544e083 Update engine.ts 2026-02-16 11:14:53 +09:00
syuilo d490891acc Merge branch 'room' of https://github.com/misskey-dev/misskey into room 2026-02-16 10:27:15 +09:00
syuilo 4da92509cb wip 2026-02-16 10:27:13 +09:00
syuilo f85223c064 Update engine.ts 2026-02-15 23:39:50 +09:00
syuilo 0397fccdb3 wip 2026-02-15 22:42:09 +09:00
syuilo c93758b554 wip 2026-02-15 22:23:31 +09:00
syuilo be67e75ef9 wip 2026-02-15 20:43:25 +09:00
syuilo d8d4b230b0 wip 2026-02-15 19:42:31 +09:00
syuilo 0996c2d9b2 wip 2026-02-15 18:33:46 +09:00
syuilo 0bcc5a3695 wip 2026-02-15 16:12:53 +09:00
syuilo e70743bf40 wip 2026-02-15 15:46:09 +09:00
syuilo 9ecb3d6a5a wip 2026-02-15 14:14:44 +09:00
syuilo 9f0fbb8531 wip 2026-02-15 11:02:21 +09:00
syuilo 52a1b30503 Update engine.ts 2026-02-14 21:48:01 +09:00
syuilo 1dec481a7e wip 2026-02-14 21:43:02 +09:00
syuilo ad48f43524 wip 2026-02-14 20:32:59 +09:00
syuilo 7efa04d561 wip 2026-02-14 10:46:32 +09:00
syuilo bf7f771760 wip 2026-02-13 20:37:41 +09:00
syuilo 3acf6db835 wip 2026-02-13 18:44:10 +09:00
syuilo aafcffd1ad wip 2026-02-13 17:23:02 +09:00
syuilo 82f68f1e93 Update engine.ts 2026-02-13 16:44:47 +09:00
syuilo 3a02ae8b28 Update engine.ts 2026-02-13 15:24:07 +09:00
syuilo 3022313fac wip 2026-02-13 14:37:01 +09:00
syuilo 22d5c27ca7 wip 2026-02-13 14:29:55 +09:00
syuilo 8665923337 wip 2026-02-13 14:10:02 +09:00
syuilo efd101d0a0 wip 2026-02-13 12:17:16 +09:00
syuilo 36f17a156f wip 2026-02-13 10:43:00 +09:00
syuilo 2e84d2864c Update engine.ts 2026-02-12 21:19:35 +09:00
syuilo 909f78b33c wip 2026-02-12 21:16:47 +09:00
syuilo cb6c790d6c wip 2026-02-12 21:06:48 +09:00
syuilo b790608f52 wip 2026-02-12 20:45:18 +09:00
syuilo 13e3bdc90b wip 2026-02-12 19:16:37 +09:00
syuilo 2a1a03ef9d wip 2026-02-12 15:55:13 +09:00
syuilo fb25331661 wip 2026-02-12 14:38:05 +09:00
syuilo 51d2b0d6a5 wip 2026-02-12 10:55:57 +09:00
syuilo de1a3e3765 wip 2026-02-12 10:49:29 +09:00
syuilo 68d28eb4ac wip 2026-02-11 19:35:00 +09:00
syuilo ad150f4718 wi0p 2026-02-11 17:00:10 +09:00
syuilo 91e3249b23 wip 2026-02-11 15:55:47 +09:00
syuilo 24a7131b0b wip 2026-02-11 15:37:16 +09:00
syuilo a0e318b43f wip 2026-02-11 14:51:51 +09:00
syuilo e41e700f2d wip 2026-02-11 11:20:49 +09:00
syuilo 6c64e75412 Update engine.ts 2026-02-11 10:44:47 +09:00
syuilo 0c5c0ce67e wip 2026-02-11 10:29:46 +09:00
syuilo 6ac091096b wip 2026-02-11 09:50:12 +09:00
syuilo b05010bdc4 wip 2026-02-10 20:35:20 +09:00
syuilo d033704f12 wip 2026-02-10 18:53:48 +09:00
syuilo 367119a5a2 wip 2026-02-10 17:30:19 +09:00
syuilo d15c971125 wip 2026-02-10 16:47:43 +09:00
syuilo 4d532199b4 wip 2026-02-10 16:27:28 +09:00
syuilo cd15906c29 wip 2026-02-10 14:33:02 +09:00
syuilo 4ee5c73bca Update engine.ts 2026-02-10 11:43:16 +09:00
syuilo cb1d9c38df Update engine.ts 2026-02-10 11:40:25 +09:00
syuilo bce3411cef refactor 2026-02-10 11:01:41 +09:00
syuilo a721a94902 Update engine.ts 2026-02-10 10:56:24 +09:00
syuilo 3173290abb wip 2026-02-10 10:47:22 +09:00
syuilo 491b40ed80 wip 2026-02-10 09:59:54 +09:00
syuilo ab1362264a Update room.vue 2026-02-09 20:43:21 +09:00
syuilo 09993a8ac8 wip 2026-02-09 20:35:22 +09:00
syuilo 8619367ac0 wip 2026-02-09 20:26:44 +09:00
syuilo 148c853000 wip 2026-02-09 18:02:17 +09:00
syuilo 14efbe6584 wip 2026-02-09 15:39:12 +09:00
syuilo 9f054bb97b Update engine.ts 2026-02-09 15:35:39 +09:00
syuilo 6f07445185 wip 2026-02-09 14:58:45 +09:00
syuilo 80b5c6cd35 Update engine.ts 2026-02-09 12:09:40 +09:00
syuilo 7ab225662c wip 2026-02-09 11:57:55 +09:00
syuilo 85dc9c738b Update engine.ts 2026-02-09 11:06:54 +09:00
syuilo a09bf963f4 wip 2026-02-09 10:52:41 +09:00
syuilo 242fd56aec Update engine.ts 2026-02-08 22:23:38 +09:00
syuilo a42fdee480 wip 2026-02-08 21:53:33 +09:00
1356 changed files with 45521 additions and 10942 deletions
+17
View File
@@ -15,3 +15,20 @@ reviews:
- "dependabot[bot]"
- "renovate[bot]"
- "github-actions[bot]"
path_instructions:
- path: "**/*"
instructions: |
【レビュー対象外】
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。
【特別な対応を要するレビュー】
- プルリクエストで変更されていない部分において、プルリクエストのタイトルや説明文に照らしてスコープ外であるが修正すべき点を見つけた場合は、このプルリクエストで修正させるのではなく、別のプルリクエストを開いてそれを修正するように指示してください。\
なお、当該プルリクエストで行が追加/削除されたことによりスコープ外の問題が起こりうる場合は、例外的にこのプルリクエストで修正させてください。
【注力してほしい観点】
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
- プルリクエストのタイトルや説明文に照らしてスコープ外の変更が含まれていないかをレビューしてください。なお、明らかにスコープ外であると確信できるもの(例: 全く関係ないファイルを編集している等)のみを対象とし、スコープ外かどうかの判断に困る場合は指摘しないでください。\
スコープ外の変更が含まれていた場合は指摘し、その部分について、このプルリクエストに含めずに別のプルリクエストを開いて変更するように指示してください。
path_filters:
- "!CHANGELOG.md"
- "!**/__snapshots__/**"
+37 -1
View File
@@ -173,8 +173,31 @@ 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'
#sentryForFrontend:
# vueIntegration:
@@ -241,7 +264,7 @@ proxyBypassHosts:
# Log settings
# logging:
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
# # Log output format. "json" outputs one-line JSON; defaults to "pretty".
# format: pretty
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
# level: info
@@ -251,6 +274,19 @@ proxyBypassHosts:
# queue: error
# queue.deliver: debug
# db.sql: off
# # HTTP status classes to record in the access log. No output when unspecified.
# access:
# statusClasses:
# - '2xx'
# - '3xx'
# - '4xx'
# - '5xx'
# # Enable body logging explicitly only during development.
# bodies:
# request: false
# response: false
# # 16 KiB by default, up to 128 KiB.
# maxBytes: 16384
# sql:
# # Outputs query parameters during SQL execution to the log.
# # default: false
+14 -58
View File
@@ -329,63 +329,6 @@ id: 'aidx'
# #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:
# tracingOptions:
@@ -474,7 +417,7 @@ proxyBypassHosts:
# Log settings
# logging:
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
# # Log output format. "json" outputs one-line JSON; defaults to "pretty".
# format: pretty
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
# level: info
@@ -484,6 +427,19 @@ proxyBypassHosts:
# queue: error
# queue.deliver: debug
# db.sql: off
# # HTTP status classes to record in the access log. No output when unspecified.
# access:
# statusClasses:
# - '2xx'
# - '3xx'
# - '4xx'
# - '5xx'
# # Enable body logging explicitly only during development.
# bodies:
# request: false
# response: false
# # 16 KiB by default, up to 128 KiB.
# maxBytes: 16384
# sql:
# # Outputs query parameters during SQL execution to the log.
# # default: false
@@ -1,239 +0,0 @@
/*
* 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);
});
@@ -1,183 +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 * 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(`Download 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`);
-570
View File
@@ -1,570 +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 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,
})),
};
}
@@ -1,279 +0,0 @@
/*
* 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();
@@ -1,446 +0,0 @@
/*
* 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();
}
@@ -1,297 +0,0 @@
/*
* 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 formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) {
const values = report.samples.map(sample => getSampleValue(sample)).filter(v => Number.isFinite(v)) as number[];
const spread = values.length < 2 ? null : util.median(values.map(value => Math.abs(value - util.median(values))));
if (spread == null) return formatter(value);
return `${formatter(value)}<br>± ${formatter(spread)}`;
}
function renderMetricRow(
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 = `${util.formatColoredDelta(summary.median, formatter, colorThreshold)}<br>${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
const deltaMedian = util.formatColoredDelta(summary.median, formatter, significantThreshold);
//return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter)} |`;
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : util.formatColoredDelta(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 renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
//function getMetric(report: BrowserMeasurement, key: string) {
// return report.performance.cdpMetrics[key];
//}
const rows = [
//metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs),
renderMetricRow('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),
renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all),
renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all),
renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all),
renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all),
renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all),
renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all),
renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all),
renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all),
renderMetricRow('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, util.formatMs),
//metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, util.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, util.formatMs),
//metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, util.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'), util.formatSecondsAsMs),
//metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), util.formatSecondsAsMs),
renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all),
renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all),
renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all),
renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all),
renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all),
renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all),
renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all),
renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all),
renderMetricRow('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">${util.formatColoredDelta(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">${util.formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
lines.push('</tr>');
}
lines.push('</tbody>');
lines.push('</table>');
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>',
'',
];
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();
}
@@ -1,527 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import * as util from './utility.mts';
const locale = 'ja-JP';
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
const stableNamedChunks = new Set(['vue', 'i18n']);
type FileEntry = {
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;
}
function findEntryKey(manifest: Manifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
?? null;
}
function stableChunkKey(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 collectStartupManifestKeys(manifest: Manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<string>();
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
function visit(key: string, importedBy?: string) {
if (keys.has(key)) return;
const chunk = manifest[key];
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);
return keys;
}
async function resolveBuiltFile(outDir: string, file: string) {
if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile);
if (await util.fileExists(localizedPath)) {
return {
absolutePath: localizedPath,
relativePath: `${locale}/${localizedFile}`,
};
}
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
}
return {
absolutePath: path.join(outDir, file),
relativePath: file,
};
}
async function collectReport(repoDir: string): 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 chunksByFile = new Map<string, FileEntry>();
const comparableChunks = new Map<string, FileEntry>();
const chunksByManifestKey = new Map<string, FileEntry>();
for (const [manifestKey, chunk] of Object.entries(manifest)) {
if (!chunk.file?.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file);
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);
if (await util.fileExists(localeDir)) {
for await (const fullPath of util.traverseDirectory(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
if (chunksByFile.has(relativePath)) continue;
chunksByFile.set(relativePath, {
comparisonKey: null,
displayName: relativePath,
file: relativePath,
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: [...chunksByFile.values()],
comparableChunks: Object.fromEntries(comparableChunks),
chunksByManifestKey: Object.fromEntries(chunksByManifestKey),
startupFiles: [...startupFiles],
};
}
type VisualizerReport = {
nodeParts?: Record<string, {
renderedLength: number;
gzipLength: number;
brotliLength: number;
}>;
nodeMetas?: Record<string, {
id: string;
isEntry?: boolean;
isExternal?: boolean;
importedBy?: string[];
imported?: { id: string; dynamic?: boolean }[];
moduleParts?: Record<string, string>;
renderedLength: number;
gzipLength: number;
brotliLength: number;
}>;
options?: Record<string, unknown>;
};
function collectVisualizerReport(data: VisualizerReport) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
] as const;
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
] as const;
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
`<tr>`,
`<th><b>Δ</b></th>`,
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 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], 0.1)}</td>`),
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
].join('\n');
}
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),
beforeFile: beforeEntry?.file,
afterFile: afterEntry?.file,
beforeSize,
afterSize,
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
sortSize: Math.max(beforeSize, afterSize),
};
});
}
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,
added: rows.filter((row) => row.changeType === 'added').length,
removed: rows.filter((row) => row.changeType === 'removed').length,
};
}
function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareChunkComparisonRows(a: 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 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, 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>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(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>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(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>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(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 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: sumChunkSizes(before.chunks),
afterSize: sumChunkSizes(after.chunks),
};
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 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: sumChunkSizes(beforeStartupChunks),
afterSize: sumChunkSizes(afterStartupChunks),
};
const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks);
return [
'<details>',
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
].join('\n');
}
const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
const afterVisualizerReport = collectVisualizerReport(afterStats);
const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
const body = [
`## 📦 Frontend Bundle Report`,
'',
renderFrontendChunkReport(before, after),
'',
'## Bundle Stats',
'',
renderVisualizerSummaryTable(beforeVisualizerReport, afterVisualizerReport),
'',
visualizerArtifactLink,
].join('\n');
await fs.writeFile(outFile, body);
-520
View File
@@ -1,520 +0,0 @@
/*
* 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');
}
-311
View File
@@ -1,311 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// 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 function sleep(ms: number) {
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
}
export function median(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
export function mad(values: number[]) {
if (values.length < 2) throw new Error('Not enough samples to calculate MAD');
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
function getSamplesByRound<T extends { round: number; }[]>(samples: T) {
const samplesByRound = new Map<number, T[number]>();
for (const sample of samples) {
if (sample.round <= 0) continue;
samplesByRound.set(sample.round, sample);
}
return samplesByRound;
}
export function pairedDeltaSummary<T extends { round: number; }[]>(baseSamples: T, headSamples: T, getValue: (sample: T[number]) => number | null) {
const baseSamplesByRound = getSamplesByRound(baseSamples);
const headSamplesByRound = getSamplesByRound(headSamples);
const values = [];
for (const [round, baseSample] of baseSamplesByRound) {
const headSample = headSamplesByRound.get(round);
if (headSample == null) continue;
const baseValue = getValue(baseSample);
const headValue = getValue(headSample);
if (baseValue == null || headValue == null) continue;
values.push(headValue - baseValue);
}
return {
median: median(values),
mad: mad(values),
min: Math.min(...values),
max: Math.max(...values),
samples: values.length,
};
}
export function normalizePath(filePath: string) {
return filePath.split(path.sep).join('/');
}
export async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function fileSize(filePath: string) {
const stat = await fs.stat(filePath);
return stat.size;
}
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* traverseDirectory(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
export function escapeLatex(text: string) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
export function escapeMdTableCell(value: string) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
export function formatMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
if (value >= 1_000) return `${formatNumber(value / 1_000)} s`;
return `${formatNumber(value)} ms`;
}
export function formatSecondsAsMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
return formatMs(value * 1_000);
}
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return text(0);
const sign = delta > 0 ? '+' : '-';
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', {
maximumFractionDigits: 1,
});
export function formatNumber(value: number) {
return numberFormatter.format(value);
}
export function formatBytes(value: number) {
if (value === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatColoredDelta(delta, v => formatNumber(v), colorThreshold);
}
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold);
}
export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) {
if (before == null || after == null) return '-';
const delta = after - before;
return formatDeltaBytes(delta, colorThreshold);
}
export function formatPercent(value: number) {
return `${formatNumber(value)}%`;
}
export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold);
}
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, colorThreshold);
}
export function commandName(command: string) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
const rawValue = process.env[name];
if (rawValue == null || rawValue === '') return defaultValue;
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
return value;
}
export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
return new Promise<string>((resolvePromise, reject) => {
const child = spawn(commandName(command), args, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
if (options.logStdout) process.stderr.write(data);
});
child.stderr.on('data', data => {
stderr += data;
process.stderr.write(data);
});
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolvePromise(stdout);
} else {
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
}
});
});
}
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();
});
}
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -17,7 +17,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter diagnostics-backend... install --frozen-lockfile
- name: Download artifacts
uses: actions/download-artifact@v8
@@ -60,7 +70,11 @@ jobs:
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
run: >-
pnpm --filter diagnostics-backend run render-md
"$GITHUB_WORKSPACE/artifacts/memory-base.json"
"$GITHUB_WORKSPACE/artifacts/memory-head.json"
"$GITHUB_WORKSPACE/output.md"
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
@@ -9,10 +9,8 @@ on:
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/scripts/utility.mts
- .github/scripts/backend-diagnostics.render-md.mts
- .github/scripts/backend-diagnostics.inspect.mts
- .github/scripts/memory-stability-util*.mts
- packages-private/diagnostics-shared/**
- packages-private/diagnostics-backend/**
- .github/workflows/backend-diagnostics.inspect.yml
- .github/workflows/backend-diagnostics.comment.yml
@@ -37,13 +35,13 @@ jobs:
steps:
- name: Checkout base
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
@@ -53,7 +51,7 @@ jobs:
with:
package_json_file: head/package.json
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: 'head/.node-version'
cache: 'pnpm'
@@ -89,11 +87,18 @@ jobs:
working-directory: head
run: pnpm build
- name: Measure backend memory usage
working-directory: head
env:
MK_MEMORY_COMPARE_ROUNDS: 10
MK_MEMORY_COMPARE_ROUNDS: 15
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
MK_MEMORY_HEAP_SNAPSHOT: 1
run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json
MK_MEMORY_HEAP_SNAPSHOT_ROUNDS: 3
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
run: >-
pnpm --filter diagnostics-backend run inspect
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head"
"$GITHUB_WORKSPACE/memory-base.json" "$GITHUB_WORKSPACE/memory-head.json"
- name: Upload base heap snapshot
uses: actions/upload-artifact@v7
with:
+15 -11
View File
@@ -12,11 +12,16 @@ jobs:
steps:
- name: Checkout head
uses: actions/checkout@v6.0.3
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/checkout@v7.0.0
- name: setup pnpm
uses: pnpm/action-setup@v6
- name: setup node
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: pnpm
- name: Checkout base
run: |
@@ -27,17 +32,16 @@ jobs:
git checkout origin/${{ github.base_ref }} CHANGELOG.md
- name: Copy to Checker directory for CHANGELOG-base.md
run: cp _base/CHANGELOG.md scripts/changelog-checker/CHANGELOG-base.md
run: cp _base/CHANGELOG.md packages-private/changelog-checker/CHANGELOG-base.md
- name: Copy to Checker directory for CHANGELOG-head.md
run: cp CHANGELOG.md scripts/changelog-checker/CHANGELOG-head.md
run: cp CHANGELOG.md packages-private/changelog-checker/CHANGELOG-head.md
- name: diff
continue-on-error: true
run: diff -u CHANGELOG-base.md CHANGELOG-head.md
working-directory: scripts/changelog-checker
working-directory: packages-private/changelog-checker
- name: Install dependencies
run: pnpm --filter changelog-checker... install --frozen-lockfile
- name: Setup Checker
run: npm install
working-directory: scripts/changelog-checker
- name: Run Checker
run: npm run run
working-directory: scripts/changelog-checker
run: pnpm --filter changelog-checker check
@@ -0,0 +1,73 @@
name: Check Misskey JS autogen (comment)
on:
workflow_run:
types: [completed]
workflows:
- Check Misskey JS autogen # check-misskey-js-autogen.yml
jobs:
comment-misskey-js-autogen:
runs-on: ubuntu-latest
# No code is cloned or executed here, so it is safe to hold pull-requests: write
if: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions:
actions: read
pull-requests: write
steps:
# The result is missing when the run failed before the check job (e.g. the PR does not build), so do not fail here
- name: Download result
id: download-result
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: misskey-js-autogen-result
path: result
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
# The artifact comes from a run that built fork code, so validate it before using
- name: Load result
id: load-result
if: steps.download-result.outcome == 'success'
run: |
pr_number="$(cat result/pr-number)"
changes="$(cat result/changes)"
case "$pr_number" in
''|*[!0-9]*) echo "invalid pr number"; exit 1;;
esac
case "$changes" in
true|false) ;;
*) echo "invalid result"; exit 1;;
esac
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
echo "changes=$changes" >> "$GITHUB_OUTPUT"
- name: send message
if: steps.load-result.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.load-result.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
+20 -29
View File
@@ -1,7 +1,8 @@
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
name: Check Misskey JS autogen
on:
pull_request_target:
pull_request:
branches:
- master
- develop
@@ -10,7 +11,6 @@ on:
- packages/backend/**
jobs:
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
generate-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -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.3
uses: actions/checkout@v7.0.0
with:
submodules: true
persist-credentials: false
@@ -29,7 +29,7 @@ jobs:
- name: setup node
id: setup-node
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: pnpm
@@ -58,7 +58,6 @@ jobs:
name: generated-misskey-js
path: packages/misskey-js/generator/built/autogen
# pull_request_target safety: permissions: read-all, and no user codes are executed
get-actual-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -66,7 +65,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
submodules: true
persist-credentials: false
@@ -78,12 +77,11 @@ jobs:
name: actual-misskey-js
path: packages/misskey-js/src/autogen
# pull_request_target safety: nothing is cloned from repository
comment-misskey-js-autogen:
check-misskey-js-autogen:
runs-on: ubuntu-latest
needs: [generate-misskey-js, get-actual-misskey-js]
permissions:
pull-requests: write
contents: read
steps:
- name: download generated-misskey-js
uses: actions/download-artifact@v8
@@ -111,28 +109,21 @@ jobs:
- name: Print full diff
run: cat ./misskey-js.diff
- name: send message
if: steps.check-changes.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
# A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
- name: Save result
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
CHANGES: ${{ steps.check-changes.outputs.changes }}
run: |
mkdir -p result
echo "$PR_NUMBER" > result/pr-number
echo "$CHANGES" > result/changes
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.check-changes.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
- name: Upload result
uses: actions/upload-artifact@v7
with:
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
name: misskey-js-autogen-result
path: result
- name: Make failure if changes are detected
if: steps.check-changes.outputs.changes == 'true'
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Check version
run: |
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Check
run: |
counter=0
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
check_copyright_year:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
- run: |
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
echo "Please change copyright year!"
+1 -1
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.3
uses: actions/checkout@v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
+1 -1
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.3
uses: actions/checkout@v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
DOCKLE_VERSION: 0.4.15
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
run: |
@@ -1,44 +0,0 @@
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
@@ -1,210 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,144 +0,0 @@
name: Frontend bundle diagnostics (inspect)
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/scripts/utility.mts
- .github/scripts/frontend-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-diagnostics-inspect-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Build frontend bundle report
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v6.0.3
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.3
with:
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: Build frontend dependencies for base
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare report output
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
- name: Build frontend report for base
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
run: pnpm --filter frontend run build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for pull request
working-directory: after
run: pnpm --filter "frontend^..." run build
- name: Build frontend report for pull request
working-directory: after
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
run: pnpm --filter frontend run build
- name: Upload bundle visualizer
id: upload-bundle-visualizer
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-visualizer
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate report markdown
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-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
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-bundle-diagnostics-report.md"
cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-report
path: ${{ runner.temp }}/frontend-bundle-report/
if-no-files-found: error
retention-days: 7
@@ -0,0 +1,75 @@
name: Frontend diagnostics (comment)
on:
workflow_run:
workflows:
- Frontend diagnostics (inspect)
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend diagnostics report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-diagnostics-report-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download frontend diagnostics report
uses: actions/download-artifact@v8
with:
name: frontend-diagnostics
path: ${{ runner.temp }}/frontend-diagnostics
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Resolve current pull request
id: resolve-pr
uses: actions/github-script@v9
env:
PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt
with:
script: |
const fs = require('fs/promises');
const { owner, repo } = context.repo;
const workflowRun = context.payload.workflow_run;
const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim();
if (!/^[1-9]\d*$/.test(pullRequestNumberText)) {
throw new Error('Invalid pull request number in frontend diagnostics artifact.');
}
const pullRequestNumber = Number(pullRequestNumberText);
if (!Number.isSafeInteger(pullRequestNumber)) {
throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.');
}
const { data: currentPullRequest } = await github.rest.pulls.get({
owner,
repo,
pull_number: pullRequestNumber,
});
if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) {
core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`);
core.setOutput('is-current', 'false');
return;
}
core.setOutput('is-current', 'true');
core.setOutput('pr-number', String(pullRequestNumber));
- name: Comment on pull request
if: steps.resolve-pr.outputs.is-current == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.resolve-pr.outputs.pr-number }}
comment-tag: frontend-diagnostics
file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
@@ -0,0 +1,246 @@
name: Frontend 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
- packages-private/diagnostics-shared/**
- packages-private/diagnostics-frontend/**
- .github/workflows/frontend-diagnostics.inspect.yml
- .github/workflows/frontend-diagnostics.comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Measure frontend diagnostics
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@v7.0.0
with:
persist-credentials: false
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v7.0.0
with:
persist-credentials: false
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: head
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:
package_json_file: head/package.json
- name: Setup Node.js
uses: actions/setup-node@v7.0.0
with:
node-version-file: head/.node-version
cache: pnpm
cache-dependency-path: |
base/pnpm-lock.yaml
head/pnpm-lock.yaml
- name: Prepare report output
shell: bash
run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics"
- name: Install dependencies for base
working-directory: base
run: pnpm i --frozen-lockfile
- name: Configure base
working-directory: base
run: cp .github/misskey/test.yml .config
- name: Build base
working-directory: base
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
run: pnpm build
- name: Install dependencies for head
working-directory: head
run: pnpm i --frozen-lockfile
- name: Configure head
working-directory: head
run: cp .github/misskey/test.yml .config
- name: Build head
working-directory: head
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
run: pnpm build
- name: Upload bundle visualizer
id: upload-bundle-visualizer
uses: actions/upload-artifact@v7
with:
name: frontend-bundle-visualizer
path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Install Playwright browsers
working-directory: head/packages-private/diagnostics-frontend
run: pnpm exec playwright install --with-deps --no-shell chromium
- name: Measure frontend browser metrics
shell: bash
working-directory: head
env:
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
pnpm --filter diagnostics-frontend run inspect-browser \
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
- name: Upload browser base heap snapshot
id: upload-browser-base-heap-snapshot
uses: actions/upload-artifact@v7
with:
name: frontend-browser-base-heap-snapshot
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:
name: frontend-browser-head-heap-snapshot
path: head-heap-snapshot.heapsnapshot
archive: false
if-no-files-found: error
retention-days: 7
- name: Generate browser detailed html
shell: bash
working-directory: head
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
test -s "$REPORT_DIR/base-browser.json"
test -s "$REPORT_DIR/head-browser.json"
pnpm --filter diagnostics-frontend run render-browser-html \
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-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-diagnostics/frontend-browser-detailed-html.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate frontend diagnostics report
shell: bash
working-directory: head
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
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-diagnostics"
test -s "$REPORT_DIR/base-bundle-stats.json"
test -s "$REPORT_DIR/head-bundle-stats.json"
test -s "$REPORT_DIR/base-browser.json"
test -s "$REPORT_DIR/head-browser.json"
pnpm --filter diagnostics-frontend run render-md \
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
"$REPORT_DIR/frontend-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 frontend diagnostics report
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
test -s "$REPORT_DIR/frontend-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-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload frontend diagnostics report
uses: actions/upload-artifact@v7
with:
name: frontend-diagnostics
path: |
${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
${{ runner.temp }}/frontend-diagnostics/base-browser.json
${{ runner.temp }}/frontend-diagnostics/head-browser.json
${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
${{ runner.temp }}/frontend-diagnostics/pr-number.txt
${{ runner.temp }}/frontend-diagnostics/base-sha.txt
${{ runner.temp }}/frontend-diagnostics/head-sha.txt
${{ runner.temp }}/frontend-diagnostics/pr-url.txt
if-no-files-found: error
retention-days: 7
+2 -2
View File
@@ -25,14 +25,14 @@ jobs:
ref: refs/pull/${{ github.event.number }}/merge
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@v7
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
+15 -9
View File
@@ -16,6 +16,8 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
@@ -32,6 +34,8 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
@@ -40,13 +44,13 @@ jobs:
pnpm_install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -69,23 +73,25 @@ jobs:
- misskey-js
- misskey-bubble-game
- misskey-reversi
- misskey-world
- frontend-misskey-world-engine
env:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- name: Restore eslint cache
uses: actions/cache@v5.0.5
uses: actions/cache@v6.1.0
with:
path: ${{ env.eslint-cache-path }}
key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
@@ -104,13 +110,13 @@ jobs:
- sw
- misskey-js
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -123,13 +129,13 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: ".node-version"
cache: "pnpm"
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+52
View File
@@ -0,0 +1,52 @@
name: Lint and test packages-private
on:
push:
branches:
- master
- develop
paths:
- packages-private/**
- packages/shared/eslint.config.js
- package.json
- pnpm-workspace.yaml
- pnpm-lock.yaml
- .github/workflows/packages-private.yml
pull_request:
paths:
- packages-private/**
- packages/shared/eslint.config.js
- package.json
- pnpm-workspace.yaml
- pnpm-lock.yaml
- .github/workflows/packages-private.yml
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
workspace:
- diagnostics-shared
- diagnostics-backend
- diagnostics-frontend
- changelog-checker
steps:
- uses: actions/checkout@v7.0.0
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm --filter ${{ matrix.workspace }}... install --frozen-lockfile
# lint = typecheck + eslint
- run: pnpm --filter ${{ matrix.workspace }} run lint
# 各パッケージは必ず test スクリプトを持たせる (無いと ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT で落ちる)
- run: pnpm --filter ${{ matrix.workspace }} run test
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
edit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PR
run: |
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
outputs:
pr_number: ${{ steps.get_pr.outputs.pr_number }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PRs
run: |
+3 -3
View File
@@ -22,12 +22,12 @@ jobs:
NODE_OPTIONS: "--max_old_space_size=7168"
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
if: github.event_name != 'pull_request_target'
with:
fetch-depth: 0
submodules: true
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
if: github.event_name == 'pull_request_target'
with:
fetch-depth: 0
@@ -39,7 +39,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+9 -9
View File
@@ -43,7 +43,7 @@ jobs:
ports:
- 56312:6379
meilisearch:
image: getmeili/meilisearch:v1.48.1
image: getmeili/meilisearch:v1.49.0
ports:
- 57712:7700
env:
@@ -51,7 +51,7 @@ jobs:
MEILI_ENV: development
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
@@ -60,7 +60,7 @@ jobs:
run: |
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
@@ -74,7 +74,7 @@ jobs:
- name: Test
run: pnpm --filter backend test-and-coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/backend/coverage/coverage-final.json
@@ -103,13 +103,13 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
@@ -123,7 +123,7 @@ jobs:
- name: Test
run: pnpm --filter backend test-and-coverage:e2e
- name: Upload to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/backend/coverage/coverage-final.json
@@ -147,7 +147,7 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
@@ -156,7 +156,7 @@ jobs:
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
- .node-version
- .github/min.node-version
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: true
- name: Setup pnpm
@@ -35,7 +35,7 @@ jobs:
run: |
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
+5 -5
View File
@@ -28,13 +28,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -48,7 +48,7 @@ jobs:
- name: Test
run: pnpm --filter frontend test-and-coverage
- name: Upload Coverage
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/frontend/coverage/coverage-final.json
@@ -71,13 +71,13 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+3 -3
View File
@@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -48,7 +48,7 @@ jobs:
CI: true
- name: Upload Coverage
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/misskey-js/coverage/coverage-final.json
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+2 -2
View File
@@ -17,13 +17,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+20 -15
View File
@@ -12,16 +12,19 @@
- 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 ではない環境においてはこの変更による影響はありません。
- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
- Enhance: 依存関係の更新
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- Enhance: 画像ビューワーを刷新・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
@@ -29,7 +32,9 @@
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Enhance: 翻訳の更新
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -37,32 +42,32 @@
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
- Fix: ローカルユーザーへのホスト付きメンションが本文に含まれる指名ノートの作成時、投稿フォームにて、当該ユーザーが宛先に含まれていても正しく認識されない問題を修正
- Fix: チャートの描画終了後にリソースが解放されない問題を修正
- Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正
- Fix: QRコードリーダーがページを離れても停止しない問題を修正
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
### Server
- Feat: OpenTelemetryサポート
- 詳細な設定はconfigファイルを参照してください。
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
- PostgreSQL query
- Redis command
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Feat: ログ基盤の刷新
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
- バックエンドのログを1行JSON形式で出力できるように
- SentryのTrace ContextをJSON形式のログへ関連付けられるように
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、SentryのTrace Contextにも対応)
- 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: Node.js 22.22.2以降、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` ヘッダーが既定で付与されないように
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
- Fix: セキュリティに関する修正
## 2026.6.0
+10
View File
@@ -30,6 +30,8 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
ARG NODE_ENV=production
@@ -61,6 +63,8 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
ARG NODE_ENV=production
@@ -99,12 +103,18 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/node_modules ./packages/misskey-world/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/node_modules ./packages/frontend-misskey-world-engine/node_modules
COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/built ./packages/misskey-world/built
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/built ./packages/frontend-misskey-world-engine/built
COPY --chown=misskey:misskey . ./
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
@@ -1,176 +0,0 @@
# 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.
+15
View File
@@ -619,6 +619,8 @@ output: "Sortida"
script: "Script"
disablePagesScript: "Desactivar AiScript a les pàgines "
updateRemoteUser: "Actualitzar la informació de l'usuari remot"
unsetMfa: "Desactiva l'autenticació de dos factors"
unsetMfaConfirm: "Voldries desactivar l'autenticació de dos factors?"
unsetUserAvatar: "Desactiva l'avatar "
unsetUserAvatarConfirm: "Segur que vols desactivar l'avatar?"
unsetUserBanner: "Desactiva el bàner "
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Afegeix al calaix d'emojis"
emojiPaletteAlreadyAddedConfirm: "Aquest emoji ja està inclòs en aquest calaix d'emojis. Vols afegir-lo de nou?"
append: "Afegeix al final"
prepend: "Afegeix al principi"
urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures"
urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà."
_imageEditing:
_vars:
caption: "Títol de l'arxiu"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "Els resultats de la detecció interna seran desats, inclòs si aquesta opció es troba desactivada."
analyzeVideos: "Activar anàlisis de vídeos "
analyzeVideosDescription: "Analitzar els vídeos a més de les imatges. Això incrementarà lleugerament la càrrega del servidor."
externalServiceInfo: "La detecció de mitjans sensibles s'ha delegat a un servei extern (sensitive-detector). Per utilitzar aquesta funció, cal configurar un servei sidecar separat i establir els detalls de connexió que es detallen a continuació. Si no es configuren els detalls de connexió, no es durà a terme cap detecció (el contingut es tractarà com a no sensible)."
apiUrl: "URL per connectar-se al servei de verificació"
apiUrlDescription: "L'URL base del servei de detector de sensibilitat (per exemple, http://localhost:3009). Si us connecteu a un servei en una xarxa privada, afegiu la xarxa de destinació a la configuració `allowedPrivateNetworks` del fitxer de configuració. Si utilitzeu un proxy, configureu també `proxyBypassHosts`. Si es deixa en blanc, no es realitzaran comprovacions de sensibilitat."
apiKey: "Clau de l'API"
apiKeyDescription: "Introduïu això si l'autenticació (token Bearer) està configurada al costat del servei d'autenticació. Si no està configurada, deixeu aquest camp en blanc."
timeout: "Temps d'espera (mil·lisegons)"
timeoutDescription: "1 Aquesta és la durada del temps mort per sol·licitud de validació."
maxImagesPerRequest: "1 Nombre màxim d'imatges per sol·licitud"
maxImagesPerRequestDescription: "1 Quan s'analitzen múltiples fotogrames, com en un vídeo, aquest és el nombre màxim d'imatges que es poden enviar en una única petició. Qualsevol imatge que superi aquest límit es dividirà en parts i s'enviarà seqüencialment. Assegureu-vos que aquest ajust no superi el valor de `maxParts` al costat del `sensitive-detector` (per defecte: 10). Si es supera aquest límit, totes les imatges d'aquest lot es consideraran no sensibles."
_emailUnavailable:
used: "Aquest correu electrònic ja s'està fent servir"
format: "El format del correu electrònic és invàlid "
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "Veure registre de moderació "
"read:admin:show-user": "Veure informació privada de l'usuari "
"write:admin:suspend-user": "Suspendre usuari"
"write:admin:unset-mfa": "Desactiva l'autenticació de dos factors per a un usuari"
"write:admin:unset-user-avatar": "Esborrar avatar d'usuari "
"write:admin:unset-user-banner": "Esborrar bàner de l'usuari "
"write:admin:unsuspend-user": "Treure la suspensió d'un usuari"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Decoració de l'avatar creada"
updateAvatarDecoration: "S'ha actualitzat la decoració de l'avatar "
deleteAvatarDecoration: "S'ha esborrat la decoració de l'avatar "
unsetMfa: "Desactiva l'autenticació de dos factors per a l'usuari"
unsetUserAvatar: "Esborrar l'avatar d'aquest usuari"
unsetUserBanner: "Esborrar el bàner d'aquest usuari"
createSystemWebhook: "Crear un SystemWebhook"
+15
View File
@@ -619,6 +619,8 @@ output: "Output"
script: "Script"
disablePagesScript: "Disable AiScript on Pages"
updateRemoteUser: "Update remote user information"
unsetMfa: "Reset two-factor authentication"
unsetMfaConfirm: "Are you sure you want to reset two-factor authentication?"
unsetUserAvatar: "Unset avatar"
unsetUserAvatarConfirm: "Are you sure you want to unset the avatar?"
unsetUserBanner: "Unset banner"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Add to emoji palette"
emojiPaletteAlreadyAddedConfirm: "This emoji is already included in this emoji palette. Do you want to add it again?"
append: "Append to end"
prepend: "Append to beginning"
urlPreviewSensitiveList: "URL to restrict thumbnail display"
urlPreviewSensitiveListDescription: "Use spaces to specify AND conditions, and line breaks to specify OR conditions. Enclose text in slashes to use regular expressions. If a match is found, the thumbnail will be hidden."
_imageEditing:
_vars:
caption: "File caption"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "The results of the internal detection will be retained even if this option is turned off."
analyzeVideos: "Enable analysis of videos"
analyzeVideosDescription: "Analyzes videos in addition to images. This will slightly increase the load on the server."
externalServiceInfo: "The detection of sensitive media has been offloaded to an external service (sensitive-detector). To use this feature, you must set up a separate service and configure the connection details provided below. If no connection details are configured, no detection will be performed (it will be treated as non-sensitive)."
apiUrl: "Detection service endpoint URL"
apiUrlDescription: "The base URL for the sensitive-detector service (e.g., http://localhost:3009). If you are connecting to a service on a private network, please allow the target network in the allowedPrivateNetworks setting in the configuration file. If you are using a proxy, please also configure proxyBypassHosts. If left blank, sensitive media detection will not be performed."
apiKey: "API key"
apiKeyDescription: "Enter this if authentication (Bearer token) is configured on the detector service. If it is not configured, please leave it blank."
timeout: "Timeout (Milliseconds)"
timeoutDescription: "Timeout duration for each judgment request."
maxImagesPerRequest: "Max images per request"
maxImagesPerRequestDescription: "Maximum number of images that can be sent in a single request when processing multi-frame at once, such as videos. Any images exceeding this limit will be split and sent sequentially. Please ensure this is set to not exceed the maxParts setting (default: 10) on the detector service. If it exceeds this limit, all items in that chunk will be treated as non-sensitive."
_emailUnavailable:
used: "This email address is already being used"
format: "The format of this email address is invalid"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "View moderation log"
"read:admin:show-user": "View private user info"
"write:admin:suspend-user": "Suspend user"
"write:admin:unset-mfa": "Reset two-factor authentication for the user"
"write:admin:unset-user-avatar": "Remove user avatar"
"write:admin:unset-user-banner": "Remove user banner"
"write:admin:unsuspend-user": "Unsuspend user"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Avatar decoration created"
updateAvatarDecoration: "Avatar decoration updated"
deleteAvatarDecoration: "Avatar decoration deleted"
unsetMfa: "Reset two-factor authentication for the user"
unsetUserAvatar: "User avatar unset"
unsetUserBanner: "User banner unset"
createSystemWebhook: "System Webhook created"
+15
View File
@@ -619,6 +619,8 @@ output: "Salida"
script: "Script"
disablePagesScript: "Deshabilitar AiScript en Páginas"
updateRemoteUser: "Actualizar información de usuario remoto"
unsetMfa: "Desactivar la autenticación de dos factores"
unsetMfaConfirm: "¿Desea desactivar la autenticación de dos factores?"
unsetUserAvatar: "Quitar avatar"
unsetUserAvatarConfirm: "¿Confirmas que quieres quitar tu avatar?"
unsetUserBanner: "Quitar banner"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Añadir a la paleta de emojis"
emojiPaletteAlreadyAddedConfirm: "Este emoji ya está incluido en esta paleta de emojis. ¿Quieres volver a añadirlo?"
append: "Añadir al final"
prepend: "Añadir al principio"
urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas"
urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura."
_imageEditing:
_vars:
caption: "Título del archivo"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "Los resultados de la detección interna pueden ser retenidos incluso si la opción está desactivada."
analyzeVideos: "Habilitar el análisis de videos"
analyzeVideosDescription: "Analizar videos en adición a las imágenes. Esto puede incrementar ligeramente la carga del servidor."
externalServiceInfo: "La detección de contenidos sensibles se ha externalizado a un servicio externo (sensitive-detector). Para utilizar esta función, es necesario configurar por separado un servicio «sidecar» y establecer los datos de conexión que se indican a continuación. Si no se configuran los datos de conexión, no se realizará la detección (se tratará como contenido no sensible)."
apiUrl: "URL de conexión al servicio de verificación"
apiUrlDescription: "URL base del servicio «sensitive-detector» (por ejemplo: http://localhost:3009). Si te conectas a un servicio situado en una red privada, debes permitir la red de destino en el parámetro «allowedPrivateNetworks» del archivo de configuración. Si utilizas un proxy, configura también el parámetro «proxyBypassHosts». Si se deja en blanco, no se realizará la evaluación de datos sensibles."
apiKey: "Clave API"
apiKeyDescription: "Introduce este dato si se ha configurado la autenticación (token Bearer) en el servicio de validación. Si no se ha configurado, déjalo en blanco."
timeout: "Tiempo de espera (milisegundos)"
timeoutDescription: "Duración del tiempo de espera para cada solicitud de resolución."
maxImagesPerRequest: "Número máximo de imágenes por solicitud"
maxImagesPerRequestDescription: "Es el número máximo de imágenes que se pueden enviar en una sola solicitud al analizar varios fotogramas, como en un vídeo. Las imágenes que superen este límite se dividirán y se enviarán de forma secuencial. Configúralo de manera que no se supere el valor de «maxParts» de sensitive-detector (por defecto: 10). Si se supera este límite, todas las imágenes de ese fragmento se considerarán no sensibles."
_emailUnavailable:
used: "Ya fue usado"
format: "Formato no válido."
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "Ver log de moderación"
"read:admin:show-user": "Ver información privada de usuario"
"write:admin:suspend-user": "Suspender cuentas de usuario"
"write:admin:unset-mfa": "Desactivar la autenticación de dos factores del usuario"
"write:admin:unset-user-avatar": "Quitar avatares de usuario"
"write:admin:unset-user-banner": "Quitar banner de usuarios"
"write:admin:unsuspend-user": "Quitar suspensión de cuentas de usuario"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Decoración de avatar creada"
updateAvatarDecoration: "Decoración de avatar actualizada"
deleteAvatarDecoration: "Decoración de avatar eliminada"
unsetMfa: "Desactivar la autenticación de dos factores del usuario"
unsetUserAvatar: "Quitar decoración de avatar de este usuario"
unsetUserBanner: "Quitar banner de este usuario"
createSystemWebhook: "Crear un SystemWebhook"
+15
View File
@@ -619,6 +619,8 @@ output: "Output"
script: "Script"
disablePagesScript: "Disabilitare AiScript nelle pagine"
updateRemoteUser: "Aggiorna dati dal profilo remoto"
unsetMfa: "Rimuovere l'autenticazione a due fattori (2FA/MFA)"
unsetMfaConfirm: "Vuoi davvero rimuovere l'autenticazione a due fattori?"
unsetUserAvatar: "Rimozione foto profilo"
unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?"
unsetUserBanner: "Rimuovi intestazione profilo"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Aggiungi alla tavolozza emoji"
emojiPaletteAlreadyAddedConfirm: "Questa emoji è già inclusa in nella tavolozza. Vuoi davvero aggiungerla?"
append: "Accodare"
prepend: "Anteporre"
urlPreviewSensitiveList: "URL da impedire alla vista delle anteprime"
urlPreviewSensitiveListDescription: "Separando con uno spazio si indica E, separando con una linea si indica O. Circondando con barre / si indica una Espressione Regolare.\nLe URL che coincidono con le indicazioni non verranno visualizzate."
_imageEditing:
_vars:
caption: "Didascalia dell'immagine"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "Anche se questa impostazione è disattivata, il risultato della decisione viene conservato internamente."
analyzeVideos: "Abilitazione dell'analisi video."
analyzeVideosDescription: "Assicuratevi che vengano analizzati anche i video oltre alle immagini fisse. Il carico del server aumenterà leggermente."
externalServiceInfo: "Abbiamo spostato esternamente il riconoscimento di media espliciti (sensitive-detector). Per usufruirne devi impostare un servizio separato e indicare di seguito la destinazione. Se non è impostata, non viene emesso alcun giudizio (contenuto NON esplicito)."
apiUrl: "URL di connessione a Sensitive-Detector"
apiUrlDescription: "L'URL di base del servizio (ad esempio http://localhost:3009). Collegandosi a una rete privata, autorizzare la rete nel parametro allowedPrivateNetworks nel file di configurazione.\nCollegandosi con un proxy, è anche necessario impostare proxyBypassHosts. Nel caso il campo sia vuoto, non vengono emessi giudizi di sensibilità."
apiKey: "Chiave API"
apiKeyDescription: "Indicare il token di autenticazione (Bearer token), soltanto se occorre, altrimenti lasciare vuoto."
timeout: "Timeout (ms)"
timeoutDescription: "Tempo limite per ogni richiesta"
maxImagesPerRequest: "Numero massimo di media per ogni richiesta"
maxImagesPerRequestDescription: "In caso ci siano più fotogrammi, come nei video, questo è il numero massimo di immagini da inviare in una richiesta. Oltre questo numero, il totale sarà diviso e inviato in sequenza.\nImpostare in modo che non superi il valore maxParts (default: 10) in Sensitive-Detector. Se viene superato, il media sarà considerato non esplicito."
_emailUnavailable:
used: "Email già in uso"
format: "Formato email non valido"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "Vedere lo storico di moderazione"
"read:admin:show-user": "Vedere le informazioni private dei profili"
"write:admin:suspend-user": "Sospendere i profili"
"write:admin:unset-mfa": "Può rimuovere l'autenticazione a due fattori (2FA/MFA)"
"write:admin:unset-user-avatar": "Rimuovere la foto profilo dai profili"
"write:admin:unset-user-banner": "Rimuovere l'immagine testata dai profili"
"write:admin:unsuspend-user": "Rimuovere la sospensione ai profili"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Crea una decorazione della foto profilo"
updateAvatarDecoration: "Modifica una decorazione della foto profilo"
deleteAvatarDecoration: "Elimina una decorazione della foto profilo"
unsetMfa: "Rimossa l'autenticazione a due fattori (2FA7MFA)"
unsetUserAvatar: "Toglie una foto profilo"
unsetUserBanner: "Toglie una immagine di intestazione profilo"
createSystemWebhook: "Aggiunge un System Webhook"
+475
View File
@@ -1414,6 +1414,8 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
previewingTheme: "テーマのプレビュー中"
previewingThemeRestore: "元に戻す"
accessToken: "アクセストークン"
choose: "選択"
rotate: "回転"
chooseEmojiPalette: "絵文字パレットを選択"
addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
@@ -3580,3 +3582,476 @@ _qr:
scanFile: "端末の画像をスキャン"
raw: "テキスト"
mfm: "MFM"
worldAvatar: "Worldアバター"
worldAvatar_description: "MisskeyWorld / MisskeyRoomsで使用可能な3Dアバターを作成できます。"
_miWorld:
separateRenderingThread: "描画を別スレッドに分離"
separateRenderingThread_description: "有効にするとパフォーマンスが向上します。不安定になる場合は無効すると改善する可能性があります。"
graphicsQuality: "グラフィックの品質"
graphicsSettings: "グラフィック設定"
frameRateLimitation: "フレームレート制限"
higherValuePerformanceNote: "高くすると体験が向上しますが、消費電力が増加するなどパフォーマンスに影響を与えます。"
resolution: "解像度"
fov: "視野角"
failedToInitialize: "初期化に失敗しました"
crushed_description: "描画が継続できなくなりました。デバイスのリソース不足の可能性が考えられます。"
antialiasing: "アンチエイリアス"
avatar: "アバター"
advancedCustomize: "高度なアレンジ"
attachAccessory: "アクセサリーをつける"
takeScreenShot: "スクリーンショット"
onlineMenu: "オンラインメニュー"
connectToOnline: "オンラインに接続"
disconnectToOnline: "オンラインから切断"
character: "キャラクター"
sit: "座る"
lyingDown: "寝そべる"
standUp: "立ち上がる"
showUsernameOnAvatar: "アバターにユーザー名を表示"
show2dAvatarOnAvatar: "アバターにユーザーアイコンを表示"
_avatars:
_default:
body: "ボディ"
eyes: "目"
mouth: "口"
_avatarAccessories:
mug: "マグカップ"
_mug:
bodyMat: "コップの素材"
liquidMat: "液体の素材"
mikan: "みかん"
bolt: "ボルト"
_bolt:
mat: "素材"
_miRoom:
snapToGrid: "グリッドにスナップ"
gridScale: "グリッドサイズ"
thereAreUnsavedChanges: "未保存の変更があります"
revertAllChangesConfirmation: "全ての変更を取り消し、部屋を最後に保存した状態まで戻しますか?"
yourDeviceNotSupported_title: "MisskeyRoomを起動できません"
yourDeviceNotSupported_description: "お使いのデバイスがMisskeyRoomをサポートしていないか、デバイスのリソース不足などにより一時的に利用できなくなっています。\nMisskeyRoomを動作させるには、WebGPUをサポートするデバイス・ブラウザが必要です。"
imageFit: "画像のはめ込み"
imageFit_cover: "覆う"
imageFit_contain: "収める"
imageFit_stretch: "伸縮"
material_metallic: "光沢"
material_roughness: "粗さ"
light_brightness: "明るさ"
advancedCustomize: "高度なアレンジ"
enterEditMode: "エディットモードを始める"
exitEditMode: "エディットモードを終了"
installFurniture: "家具を設置"
roomCustomize: "部屋のカスタマイズ"
changeRoomName: "ルーム名を編集"
roomInfo: "ルーム情報"
duplicate: "複製"
grab: "掴む"
furnitureCustomize: "家具のアレンジ"
uninstallFurniture: "しまう"
furnituresCount: "家具の数"
attachedFilesCount: "添付ファイル数"
_furniturePlacement:
top: "上面設置"
bottom: "下面設置"
side: "側面設置"
_furnitures:
haniwa: "はにわ"
_haniwa:
bodyMat: "本体の素材"
insideColor: "中身の色"
woodRingFloorLamp: "リングシェードフロアランプ"
_woodRingFloorLamp:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
light: "照明"
a4Case: "A4ケース"
_a4Case:
mat: "素材"
aircon: "エアコン"
allInOnePc: "一体型PC"
_allInOnePc:
bezelMat: "ベゼルの素材"
bodyMat: "本体の素材"
image: "画面の画像"
image_desktop: "デスクトップ"
screenBrightness: "画面の明るさ"
aquarium: "水槽"
aromaReedDiffuser: "アロマリードディフューザー"
_aromaReedDiffuser:
bottleMat: "ボトルの素材"
oilMat: "オイルの素材"
banknote: "紙幣"
beamLamp: "ビームランプ"
bed: "ベッド"
_bed:
frameMat: "フレームの素材"
blind: "ブラインド"
_blind:
angle: "羽根の回転角度"
blades: "羽根の枚数"
open: "開閉状態"
book: "本"
_book:
height: "高さ"
thickness: "厚み"
variation: "バリエーション"
width: "幅"
books: "本の束"
_books:
variation: "バリエーション"
boxWallShelf: "ボックス型ウォールシェルフ"
_boxWallShelf:
bodyMat: "本体の素材"
height: "高さ"
width: "幅"
withBack: "背板"
cactusS: "サボテン S"
_cactusS:
potMat: "鉢の素材"
cardboardBox: "段ボール箱"
_cardboardBox:
variation: "種類"
variation_aizon: "Aizon"
variation_default: "デフォルト"
variation_mikan: "みかん"
ceilingFanLight: "シーリングファンライト"
_ceilingFanLight:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
ceilingFan: "シーリングファン"
_ceilingFan:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
chair: "椅子"
_chair:
primaryMat: "メインの素材"
secondaryMat: "サブの素材"
frameMat: "フレームの素材"
clippedPicture: "留められた写真"
_clippedPicture:
height: "高さ"
image: "画像"
width: "幅"
coffeeCup: "コーヒーカップ"
colorBox: "カラーボックス"
_colorBox:
mat: "素材"
cuboid: "直方体"
_cuboid:
mat: "素材"
x: "X"
y: "Y"
z: "Z"
cupNoodle: "インスタントラーメン"
curtain: "カーテン"
custardPudding: "プリン"
descriptionPlate: "説明が書かれたプレート"
desk: "デスク"
_desk:
boardMat: "天板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
width: "幅"
desktopPc: "デスクトップPC"
_desktopPc:
bodyMat: "本体の素材"
coverMat: "カバーの素材"
inner1Mat: "内部素材1"
inner2Mat: "内部素材2"
inner3Mat: "内部素材3"
ledColor: "LEDの色"
djMixer: "DJミキサー"
djPlayer: "DJプレーヤー"
_djPlayer:
image: "画像"
"image:waveform": "波形"
screenBrightness: "画面の明るさ"
ductRailSpotLights: "スポットライト付きダクトレール"
_ductRailSpotLights:
angleH: "水平角度"
angleV: "垂直角度"
bodyMat: "本体の素材"
light: "照明"
ductTape: "ガムテープ"
herbarium: "ハーバリウム"
electronicDisplayBoard: "電光掲示板"
_electronicDisplayBoard:
frameMat: "フレームの素材"
ledBrightness: "LEDの明るさ"
ledColor: "LEDの色"
text: "テキスト"
emptyBento: "空の弁当容器"
energyDrink: "エナジードリンク"
envelope: "封筒"
facialTissue: "ティッシュ"
glassCylinderPotPlant: "ガラスシリンダーの鉢植えと植物"
handheldGameConsole: "携帯ゲーム機"
_handheldGameConsole:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
hangingDuctRail: "吊り下げダクトレール"
_hangingDuctRail:
bodyMat: "本体の素材"
height: "高さ"
width: "幅"
hangingTShirt: "吊り下げTシャツ"
icosahedron: "正二十面体のオブジェ"
_icosahedron:
mat: "素材"
ironFrameTable: "アイアンフレームテーブル"
_ironFrameTable:
boardMat: "天板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
height: "高さ"
width: "幅"
issyoubin: "一升瓶"
_issyoubin:
variation: "種類"
keyboard: "キーボード"
_keyboard:
bodyMat: "本体の素材"
keyMat: "キーの素材"
laptopPc: "ノートPC"
_laptopPc:
bezelMat: "ベゼルの素材"
bodyMat: "本体の素材"
image: "画像"
openAngle: "開き具合"
screenBrightness: "画面の明るさ"
largeMousepad: "大きいマウスパッド"
_largeMousepad:
image: "画像"
lavaLamp: "ラバランプ"
_lavaLamp:
bodyMat: "本体の素材"
glassMat: "ガラスの素材"
lavaColor: "オイルの色"
lightColor: "ランプの色"
letterCase: "レターケース"
lowPartitionBar: "低いパーティションバー"
_lowPartitionBar:
bodyMat: "本体の素材"
width: "幅"
miObjet: "Miオブジェ"
miPlate: "Miプレート"
miPlateDisplayed: "飾られたMiプレート"
milk: "牛乳"
mixer: "ミキサー"
monitor: "モニター"
_monitor:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
monitorSpeaker: "モニタースピーカー"
_monitorSpeaker:
mat: "素材"
monstera: "モンステラ"
_monstera:
potMat: "鉢の素材"
mug: "マグカップ"
_mug:
bodyMat: "コップの素材"
liquidMat: "液体の素材"
newtonsCradle: "ニュートンクレードル"
_newtonsCradle:
frameMat: "フレームの素材"
openedCardboardBox: "開いた段ボール箱"
pachira: "パキラ"
_pachira:
potMat: "鉢の素材"
petBottle: "ペットボトル"
_petBottle:
empty: "空"
variation: "種類"
variation_greenTea: "緑茶"
variation_mineralWater: "ミネラルウォーター"
withCap: "キャップ"
withLabel: "ラベル"
piano: "ピアノ"
_piano:
bodyMat: "本体の素材"
pictureFrame: "シンプルな額縁"
_pictureFrame:
depth: "厚さ"
frameMat: "フレームの素材"
frameThickness: "フレームの幅"
height: "高さ"
image: "画像"
matHThickness: "マットの横幅"
matVThickness: "マットの縦幅"
width: "幅"
withCover: "カバーあり"
pizza: "ピザ"
plant: "植物"
plant2: "植物 2"
poster: "ポスター"
_poster:
height: "高さ"
image: "画像"
width: "幅"
powerStrip: "電源タップ"
radiometer: "ラジオメーター"
randomBooks: "雑多な本"
_randomBooks:
count: "数"
seed: "シード"
stackVertically: "平積み"
variation: "バリエーション"
variation_mix: "いろいろ"
variation_mixPlain: "いろいろ(無地)"
recordPlayer: "レコードプレーヤー"
rolledUpPoster: "丸めたポスター"
roundRug: "円形のラグ"
router: "ルーター"
siphon: "サイフォン"
snakeplant: "サンセベリア"
_snakeplant:
potMat: "鉢の素材"
sofa: "ソファ"
_sofa:
bodyMat: "本体の素材"
speaker: "スピーカー"
_speaker:
innerMat: "内側の素材"
outerMat: "外側の素材"
speakerStand: "スピーカースタンド"
_speakerStand:
bodyMat: "本体の素材"
height: "高さ"
spotLight: "スポットライト"
_spotLight:
angleH: "横方向の角度"
angleV: "縦方向の角度"
bodyMat: "本体の素材"
light: "照明"
downlight: "ダウンライト"
_downlight:
bodyMat: "本体の素材"
light: "照明"
sprayer: "霧吹き"
stanchionPole: "スタンションポール"
_stanchionPole:
bodyMat: "本体の素材"
ropeMat: "ロープの素材"
steelRack: "スチールラック"
_steelRack:
height: "高さ"
numberOfShelfs: "シェルフの数"
poleMat: "ポールの素材"
shelfPositionOf: "シェルフの位置"
shelfMat: "シェルフの素材"
widthAndDepthVariation: "W x D"
stormGlass: "ストームグラス"
tableSalt: "食卓塩"
tabletopCalendar: "卓上カレンダー"
tabletopDigitalClock: "卓上デジタル時計"
_tabletopDigitalClock:
bodyMat: "本体の素材"
lcdColor: "LCDの色"
tabletopFlag: "卓上旗"
_tabletopFlag:
image: "画像"
tabletopGlassPictureFrame: "卓上ガラス製フォトフレーム"
_tabletopGlassPictureFrame:
height: "高さ"
image: "画像"
width: "幅"
tabletopIronFrameStand: "卓上アイアンフレームスタンド"
_tabletopIronFrameStand:
boardMat: "板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
height: "高さ"
width: "幅"
tabletopLcdButtonsController: "LCDボタン付き卓上コントローラー"
_tabletopLcdButtonsController:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
tabletopPictureFrame: "卓上フォトフレーム"
_tabletopPictureFrame:
depth: "厚さ"
frameMat: "フレームの素材"
frameThickness: "フレームの幅"
height: "高さ"
image: "画像"
matHThickness: "マットの横幅"
matVThickness: "マットの縦幅"
width: "幅"
tapestry: "タペストリー"
_tapestry:
height: "高さ"
image: "画像"
width: "幅"
tetrapod: "波消ブロックの置物"
tv: "テレビ"
_tv:
bodyMat: "本体の素材"
screenBrightness: "画面の明るさ"
twistedCubeObjet: "ねじれた立方体のオブジェ"
usedTissue: "使用済みティッシュ"
wallCanvas: "壁掛けキャンバス"
_wallCanvas:
height: "高さ"
image: "画像"
width: "幅"
wallClock: "壁掛け時計"
_wallClock:
frameMat: "フレームの素材"
faceMat: "文字盤の素材"
handsMat: "針の素材"
wallGlassPictureFrame: "ガラスの壁掛けフォトフレーム"
_wallGlassPictureFrame:
height: "高さ"
image: "画像"
width: "幅"
wallMirror: "壁掛けミラー"
_wallMirror:
frameMat: "フレームの素材"
frameThickness: "フレームの厚み"
height: "高さ"
width: "幅"
wallMountSpotLight: "ウォールマウントスポットライト"
_wallMountSpotLight:
angleH: "横方向の角度"
angleV: "縦方向の角度"
bodyMat: "本体の素材"
light: "照明"
wallShelf: "ウォールシェルフ"
_wallShelf:
boardMat: "板の素材"
boardStyle: "板のスタイル"
"boardStyle:color": "単色"
"boardStyle:wood": "木目"
style: "スタイル"
wireBasket: "ワイヤーバスケット"
_wireBasket:
bodyMat: "本体の素材"
wireNet: "ワイヤーネット"
_wireNet:
bodyMat: "本体の素材"
woodRingsPendantLight: "リングペンダントライト"
_woodRingsPendantLight:
bodyMat: "本体の素材"
length: "長さ"
light: "照明"
shadeMat: "シェードの素材"
woodSoundAbsorbingPanel: "木製吸音パネル"
ironFrameShelf: "アイアンフレームシェルフ"
_ironFrameShelf:
boardMat: "板の素材"
frameMat: "フレームの素材"
height: "段数"
width: "幅"
kakejiku: "掛軸"
_kakejiku:
image: "画像"
"image:kitsuaishinsei": "喫藍心整"
+15
View File
@@ -619,6 +619,8 @@ output: "출력"
script: "스크립트"
disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음"
updateRemoteUser: "리모트 유저 정보 갱신"
unsetMfa: "2단계 인증 해제"
unsetMfaConfirm: "2단계 인증을 해제하시겠습니까?"
unsetUserAvatar: "아바타 제거"
unsetUserAvatarConfirm: "아바타를 제거할까요?"
unsetUserBanner: "배너 제거"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "이모지 팔레트에 추가"
emojiPaletteAlreadyAddedConfirm: "이 이모지는 이미 이 이모지 팔레트에 포함돼있습니다. 다시 추가하시겠습니까?"
append: "맨뒤에 추가"
prepend: "맨앞에 추가"
urlPreviewSensitiveList: "썸네일 표시 제한 URL"
urlPreviewSensitiveListDescription: "공백으로 구분하면 AND 지정으로 되고, 줄내림으로 구분하면 OR 지정으로 됩니다. 슬래시로 감싸면 정규 표현으로 됩니다. 일치한 경우에 썸네일이 표시되지 않게 됩니다."
_imageEditing:
_vars:
caption: "파일 설명"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지됩니다."
analyzeVideos: "동영상도 같이 확인하기"
analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지합니다. 서버의 부하를 약간 증가시킵니다."
externalServiceInfo: "민감한 미디어 판정은 외부 서비스(sensitive-detector)로 분리됐습니다. 이 기능을 이용하려면 별도 사이드카 서비스를 설정하고, 아래의 접속 위치를 설정해야 합니다. 접속 위치가 설정되지 않은 경우에는 판정이 이루어지지 않습니다. (민감하지 않음 처리)"
apiUrl: "판정 서비스의 접속 위치 URL"
apiUrlDescription: "sensitive-detector 서비스의 베이스 URL(예시: http://localhost:3009). 프라이빗 네트워크상의 서비스에 접속하는 경우에는 설정 파일의 allowedPrivateNetworks로 접속 위치 네트워크를 허가해 주십시오. 프록시를 사용하고 있는 경우에는 proxyBypassHosts도 설정해 주십시오. 비어있으면 민감함 판정은 이루어지지 않습니다."
apiKey: "API 키"
apiKeyDescription: "판정 서비스 측에서 인증(Bearer 토큰)을 설정하고 있는 경우에 입력합니다. 설정하고 있지 않은 경우에는 빈칸으로 둬주십시오."
timeout: "타임아웃 (밀리초)"
timeoutDescription: "판정 요청 1회당 타임아웃 시간입니다."
maxImagesPerRequest: "한 요청당 최대 이미지 수"
maxImagesPerRequestDescription: "동영상 등 여러 프레임을 판정할 때, 한 번의 요청에 모아서 보내는 이미지의 최대 장수입니다. 이를 넘으면 분할해 순차적으로 송신됩니다. sensitive-detector 측의 maxParts 설정(기본: 10)을 남지 않도록 설정해 주십시오. 넘은 경우에는 그 청크는 전부 민감하지 않음 처리로 됩니다."
_emailUnavailable:
used: "이 메일 주소는 사용중입니다"
format: "형식이 올바르지 않습니다"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "조정 기록 보기"
"read:admin:show-user": "유저 개인정보 보기"
"write:admin:suspend-user": "유저 정지하기"
"write:admin:unset-mfa": "사용자의 2단계 인증 해제"
"write:admin:unset-user-avatar": "유저 아바타 삭제하기"
"write:admin:unset-user-banner": "유저 배너 삭제하기"
"write:admin:unsuspend-user": "유저 정지 해제하기"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "아바타 장식 만들기"
updateAvatarDecoration: "아바타 장식 수정"
deleteAvatarDecoration: "아바타 장식 삭제"
unsetMfa: "사용자의 2단계 인증 해제"
unsetUserAvatar: "유저 아바타 제거"
unsetUserBanner: "유저 배너 제거"
createSystemWebhook: "SystemWebhook을 생성"
+20 -5
View File
@@ -57,7 +57,7 @@ deleteAndEditConfirm: "要删除该帖并重新编辑吗?该帖下的所有回
addToList: "添加至列表"
addToAntenna: "添加到天线"
sendMessage: "发送消息"
copyRSS: "复制RSS"
copyRSS: "复制 RSS"
copyUsername: "复制用户名"
copyUserId: "复制用户 ID"
copyNoteId: "复制帖子 ID"
@@ -456,8 +456,8 @@ about: "关于"
aboutMisskey: "关于 Misskey"
administrator: "管理员"
token: "Token (令牌)"
2fa: "双因素认证"
setupOf2fa: "设置双因素认证"
2fa: "双重验证"
setupOf2fa: "设置双重验证"
totp: "验证器"
totpDescription: "使用验证器输入一次性密码"
moderator: "监察员"
@@ -485,7 +485,7 @@ markAsReadAllNotifications: "将所有通知标为已读"
markAsReadAllUnreadNotes: "将所有帖子标记为已读"
markAsReadAllTalkMessages: "将所有私信标记为已读"
help: "帮助"
inputMessageHere: "在此入信息"
inputMessageHere: "在此入信息"
close: "关闭"
invites: "邀请"
members: "成员"
@@ -619,6 +619,8 @@ output: "输出"
script: "脚本"
disablePagesScript: "禁用页面脚本"
updateRemoteUser: "更新远程用户信息"
unsetMfa: "解除双重验证"
unsetMfaConfirm: "确认解除双重验证吗?"
unsetUserAvatar: "清除头像"
unsetUserAvatarConfirm: "要清除头像吗?"
unsetUserBanner: "清除横幅"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "添加至表情符号选择器"
emojiPaletteAlreadyAddedConfirm: "此表情符号已存在于此表情符号选择器中。要再次添加吗?"
append: "加到最后"
prepend: "加到最前"
urlPreviewSensitiveList: "限制显示缩略图的 URL"
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
_imageEditing:
_vars:
caption: "文件标题"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "即使关闭此配置,识别结果也会在内部保存。"
analyzeVideos: "启用对视频的检测"
analyzeVideosDescription: "除了静止图像之外,还对视频进行分析。服务器负载会略微增加。"
externalServiceInfo: "检测敏感媒体已分离至外部服务 (sensitive-detector)。若要使用,需额外部署 Sidecar 服务,并设置下方的连接 URL。未设定时将不会进行检测(视为非敏感媒体)。"
apiUrl: "检测服务的连接 URL"
apiUrlDescription: "sensitive-detector 服务的 base URL(如:http://localhost:3009)。若是连接至部署在专用网络上的服务,请在配置文件中的 allowedPrivateNetworks 里允许目标网络。若是使用了代理,请一并设置 proxyBypassHosts。留空则不进行敏感媒体检测。"
apiKey: "API 密钥"
apiKeyDescription: "若服务端有设置验证(Bearer token)则填写,未设置则留空。"
timeout: "超时(毫秒)"
timeoutDescription: "此为单次检测请求的超时时长。"
maxImagesPerRequest: "单次检测请求最大图像数量"
maxImagesPerRequestDescription: "此为在检测动画等多帧图像时,单次请求中可发送的图像数量上限。超出此值时动画将被拆分并按序发送。请勿将此值设为超出 sensitive-detector 侧的 maxParts 的值(默认:10),否则对应的分块将全被视为非敏感媒体。"
_emailUnavailable:
used: "已经被使用过"
format: "无效的格式"
@@ -2461,6 +2474,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": "解除用户冻结"
@@ -2586,7 +2600,7 @@ _widgetOptions:
_jobQueue:
sound: "播放音效"
_rss:
url: "RSS feed 的 URL"
url: "RSS 订阅源网址"
refreshIntervalSec: "更新间隔(秒)"
maxEntries: "最大显示个数"
_rssTicker:
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "新建头像挂件"
updateAvatarDecoration: "更新头像挂件"
deleteAvatarDecoration: "删除头像挂件"
unsetMfa: "解除用户的双重验证"
unsetUserAvatar: "清除用户头像"
unsetUserBanner: "清除用户横幅"
createSystemWebhook: "新建了 SystemWebhook"
+15 -11
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.1",
"version": "2026.7.0-rc.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -8,17 +8,21 @@
},
"packageManager": "pnpm@11.11.0",
"workspaces": [
"packages/misskey-js",
"packages/backend",
"packages/frontend-shared",
"packages/frontend",
"packages/frontend-builder",
"packages/frontend-embed",
"packages/i18n",
"packages/icons-subsetter",
"packages/sw",
"packages/misskey-js",
"packages/misskey-js/generator",
"packages/misskey-reversi",
"packages/misskey-bubble-game",
"packages/icons-subsetter",
"packages/frontend-shared",
"packages/frontend-builder",
"packages/sw",
"packages/backend",
"packages/frontend",
"packages/frontend-embed"
"packages/misskey-world",
"packages/frontend-misskey-world-engine",
"packages-private/*"
],
"private": true,
"scripts": {
@@ -55,8 +59,8 @@
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "5.2.1",
"tar": "7.5.20"
"js-yaml": "5.2.2",
"tar": "7.5.21"
},
"devDependencies": {
"@eslint/js": "9.39.5",
+32
View File
@@ -0,0 +1,32 @@
# packages-private
`packages-private` は、CIなどで使用するためのユーティリティなどを格納する場所です。この配下にあるものは**プロダクションの `/packages`**の依存となるものではありません。
## パッケージ一覧
| パッケージ | 用途 |
| --- | --- |
| [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ |
| [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する |
| [`diagnostics-frontend`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する |
| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する |
## GitHub Actionsからの呼び出し方
ワークフロー側にロジックを持たせず、各パッケージのnpm scriptを呼ぶだけにしている。
CLIに渡すパスはすべて呼び出し側のカレントディレクトリ基準で解決されるため、
ワークフローからは `$GITHUB_WORKSPACE` / `$RUNNER_TEMP` を使った絶対パスを渡すこと。
```yaml
- name: Generate report
working-directory: head
run: >-
pnpm --filter diagnostics-frontend run render-md
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head"
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json"
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
"$REPORT_DIR/report.md"
```
base/head を比較する計測では、**head側のチェックアウトにあるハーネスで両方を計測する**
(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。
@@ -1,10 +1,16 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../packages/shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
ignores: [
'**/node_modules',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
@@ -0,0 +1,24 @@
{
"name": "changelog-checker",
"private": true,
"type": "module",
"scripts": {
"check": "tsx src/index.ts",
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"lint": "pnpm typecheck && pnpm eslint",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/mdast": "4.0.4",
"@types/node": "26.1.1",
"@vitest/coverage-v8": "4.1.10",
"mdast-util-to-string": "4.0.0",
"remark": "15.0.1",
"remark-parse": "11.0.0",
"tsx": "4.23.1",
"unified": "11.0.5",
"vitest": "4.1.10"
}
}
@@ -33,16 +33,22 @@ export function checkNewRelease(base: Release[], head: Release[]): Result {
return Result.ofFailed('Invalid release count.');
}
const baseLatest = base[0];
const headPrevious = head[releaseCountDiff];
if (baseLatest.releaseName !== headPrevious.releaseName) {
return Result.ofFailed('Contains unexpected releases.');
// 追加分を除いた残り (= base に既にあったリリース) が順序ごと一致することを確認する。
// 先頭だけ比較していると、より古いリリースが書き換えられていても素通りしてしまう
const existingReleases = head.slice(releaseCountDiff);
for (let relIdx = 0; relIdx < base.length; relIdx++) {
if (base[relIdx].releaseName !== existingReleases[relIdx].releaseName) {
return Result.ofFailed(`Contains unexpected releases. base:${base[relIdx].releaseName}, head:${existingReleases[relIdx].releaseName}`);
}
}
return Result.ofSuccess();
}
function isSameItems(base: string[], head: string[]) {
return base.length === head.length && base.every((item, idx) => item === head[idx]);
}
/**
* topic -> developまたはtopic -> masterを想定したパターン
* head側の最新リリース配下に書き加えられているかをチェックする
@@ -78,9 +84,9 @@ export function checkNewTopic(base: Release[], head: Release[]): Result {
return Result.ofFailed(`Category is different. base:${baseCategory.categoryName}, head:${headCategory.categoryName}`);
}
if (baseCategory.items.length !== headCategory.items.length) {
if (!isSameItems(baseCategory.items, headCategory.items)) {
if (headLatest.releaseName !== headItem.releaseName) {
// 最新リリース以外に追記されていた場合
// 最新リリース以外が変更されていた場合
return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`);
}
}
@@ -18,7 +18,7 @@ function abort(message?: string) {
function main() {
if (!fs.existsSync('./CHANGELOG-base.md') || !fs.existsSync('./CHANGELOG-head.md')) {
console.error('CHANGELOG-base.md or CHANGELOG-head.md is missing.');
abort('CHANGELOG-base.md or CHANGELOG-head.md is missing.');
return;
}
@@ -51,6 +51,9 @@ export function parseChangeLog(path: string): Release[] {
// リリース
release = new Release(toString(it));
releases.push(release);
// 直前のリリースのカテゴリを引き継ぐと、カテゴリ見出しの無いリスト項目が
// 前のリリースに混入するのでリセットする
category = null;
} else if (isHeading(it) && it.depth === 3 && release) {
// リリース配下のカテゴリ
category = new ReleaseCategory(toString(it));
@@ -3,52 +3,62 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {expect, suite, test} from "vitest";
import {Release, ReleaseCategory} from "../src/parser";
import {checkNewRelease, checkNewTopic} from "../src/checker";
import { expect, describe, test } from 'vitest';
import { Release, ReleaseCategory } from '../src/parser.js';
import { checkNewRelease, checkNewTopic } from '../src/checker.js';
suite('checkNewRelease', () => {
describe('checkNewRelease', () => {
test('headに新しいリリースがある1', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.1'), new Release('2024.12.0')]
const base = [new Release('2024.12.0')];
const head = [new Release('2024.12.1'), new Release('2024.12.0')];
const result = checkNewRelease(base, head)
const result = checkNewRelease(base, head);
expect(result.success).toBe(true)
})
expect(result.success).toBe(true);
});
test('headに新しいリリースがある2', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')]
const base = [new Release('2024.12.0')];
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')];
const result = checkNewRelease(base, head)
expect(result.success).toBe(true)
})
const result = checkNewRelease(base, head);
expect(result.success).toBe(true);
});
test('リリースの数が同じ', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.0')]
const base = [new Release('2024.12.0')];
const head = [new Release('2024.12.0')];
const result = checkNewRelease(base, head)
const result = checkNewRelease(base, head);
console.log(result.message)
expect(result.success).toBe(false)
})
console.log(result.message);
expect(result.success).toBe(false);
});
test('baseにあるリリースがheadにない', () => {
const base = [new Release('2024.12.0')]
const head = [new Release('2024.12.2'), new Release('2024.12.1')]
const base = [new Release('2024.12.0')];
const head = [new Release('2024.12.2'), new Release('2024.12.1')];
const result = checkNewRelease(base, head)
const result = checkNewRelease(base, head);
console.log(result.message)
expect(result.success).toBe(false)
})
})
console.log(result.message);
expect(result.success).toBe(false);
});
suite('checkNewTopic', () => {
// 先頭だけ比較していると、より古いリリースの書き換えを見逃す
test('追加分の直後は一致しているが、より古いリリースが書き換えられている', () => {
const base = [new Release('2024.12.1'), new Release('2024.12.0')];
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.11.0')];
const result = checkNewRelease(base, head);
console.log(result.message);
expect(result.success).toBe(false);
});
});
describe('checkNewTopic', () => {
test('追記なし', () => {
const base = [
new Release('2024.12.1', [
@@ -59,7 +69,7 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
@@ -69,9 +79,9 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
]),
]),
];
const head = [
new Release('2024.12.1', [
@@ -82,7 +92,7 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
@@ -92,14 +102,14 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
]),
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
expect(result.success).toBe(true)
})
expect(result.success).toBe(true);
});
test('最新バージョンにカテゴリを追加したときはエラーにならない', () => {
const base = [
@@ -117,9 +127,9 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
]),
]),
];
const head = [
new Release('2024.12.1', [
@@ -130,7 +140,7 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
@@ -140,14 +150,14 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
]),
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
expect(result.success).toBe(true)
})
expect(result.success).toBe(true);
});
test('最新バージョンからカテゴリを削除したときはエラーにならない', () => {
const base = [
@@ -159,7 +169,7 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
@@ -169,9 +179,9 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
]),
]),
];
const head = [
new Release('2024.12.1', [
@@ -188,14 +198,14 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Client', [
'feat3',
'feat4',
])
])
]
]),
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
expect(result.success).toBe(true)
})
expect(result.success).toBe(true);
});
test('最新バージョンに追記したときはエラーにならない', () => {
const base = [
@@ -210,8 +220,8 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const head = [
new Release('2024.12.1', [
@@ -226,13 +236,13 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
expect(result.success).toBe(true)
})
expect(result.success).toBe(true);
});
test('最新バージョンから削除したときはエラーにならない', () => {
const base = [
@@ -247,8 +257,8 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const head = [
new Release('2024.12.1', [
@@ -261,13 +271,13 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
expect(result.success).toBe(true)
})
expect(result.success).toBe(true);
});
test('古いバージョンにカテゴリを追加したときはエラーになる', () => {
const base = [
@@ -282,8 +292,8 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const head = [
new Release('2024.12.1', [
@@ -301,14 +311,14 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
console.log(result.message)
expect(result.success).toBe(false)
})
console.log(result.message);
expect(result.success).toBe(false);
});
test('古いバージョンからカテゴリを削除したときはエラーになる', () => {
const base = [
@@ -323,8 +333,8 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const head = [
new Release('2024.12.1', [
@@ -334,14 +344,14 @@ suite('checkNewTopic', () => {
]),
]),
new Release('2024.12.0', [
])
]
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
console.log(result.message)
expect(result.success).toBe(false)
})
console.log(result.message);
expect(result.success).toBe(false);
});
test('古いバージョンに追記したときはエラーになる', () => {
const base = [
@@ -356,8 +366,8 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const head = [
new Release('2024.12.1', [
@@ -372,14 +382,14 @@ suite('checkNewTopic', () => {
'feat2',
'feat3',
]),
])
]
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
console.log(result.message)
expect(result.success).toBe(false)
})
console.log(result.message);
expect(result.success).toBe(false);
});
test('古いバージョンから削除したときはエラーになる', () => {
const base = [
@@ -394,8 +404,8 @@ suite('checkNewTopic', () => {
'feat1',
'feat2',
]),
])
]
]),
];
const head = [
new Release('2024.12.1', [
@@ -408,12 +418,88 @@ suite('checkNewTopic', () => {
new ReleaseCategory('Server', [
'feat1',
]),
])
]
]),
];
const result = checkNewTopic(base, head)
const result = checkNewTopic(base, head);
console.log(result.message)
expect(result.success).toBe(false)
})
})
console.log(result.message);
expect(result.success).toBe(false);
});
// 件数が同じでも内容が変わっていれば履歴の書き換えなのでエラーにする
test('古いバージョンの項目が書き換えられたときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', ['feat1']),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', ['feat1', 'feat2']),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', ['feat1']),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', ['feat1', 'feat2-rewritten']),
]),
];
const result = checkNewTopic(base, head);
console.log(result.message);
expect(result.success).toBe(false);
});
test('古いバージョンの項目が並べ替えられたときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', ['feat1']),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', ['feat1', 'feat2']),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', ['feat1']),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', ['feat2', 'feat1']),
]),
];
const result = checkNewTopic(base, head);
console.log(result.message);
expect(result.success).toBe(false);
});
// 最新リリースの書き換えは通常の編集なので許容する
test('最新バージョンの項目を書き換えたときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', ['feat1']),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', ['feat1']),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', ['feat1-rewritten']),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', ['feat1']),
]),
];
const result = checkNewTopic(base, head);
expect(result.success).toBe(true);
});
});
@@ -14,18 +14,17 @@
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"typeRoots": [
"./node_modules/@types"
],
"skipLibCheck": true,
"types": ["node"],
"lib": [
"esnext"
]
},
"include": [
"src/**/*"
"src/**/*",
"test/**/*"
],
"exclude": [
"node_modules",
"test/**/*"
]
}
@@ -0,0 +1,25 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../packages/shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
ignores: [
'**/node_modules',
'**/__snapshots__',
'test/fixtures',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];
@@ -0,0 +1,27 @@
{
"name": "diagnostics-backend",
"private": true,
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"inspect": "tsx src/inspect.ts",
"lint": "pnpm typecheck && pnpm eslint",
"measure": "tsx src/measure-once.ts",
"render-md": "tsx src/render-md.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"diagnostics-shared": "workspace:*",
"execa": "9.6.1"
},
"devDependencies": {
"@types/node": "26.1.1",
"@types/pg": "8.20.0",
"ioredis": "5.11.1",
"pg": "8.22.0",
"tsx": "4.23.1",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,197 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { copyFile, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { execa } from 'execa';
import { readBooleanEnv, readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { median } from 'diagnostics-shared/stats';
import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
import { resetState } from './db';
import {
clampHeapSnapshotRounds,
selectRepresentativeHeapSnapshotRound,
shouldCollectHeapSnapshot,
} from './heap-snapshot-sampling';
import { measureBackendMemory } from './measure';
import { memoryPhases, type MemoryReport } from './types';
const heapSnapshotLabels = ['base', 'head'] as const;
type HeapSnapshotLabel = typeof heapSnapshotLabels[number];
export type CompareOptions = {
baseDir: string;
headDir: string;
baseOutput: string;
headOutput: string;
};
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す
const HEAP_SNAPSHOT_OUTPUT_DIR = resolve(readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd());
const HEAP_SNAPSHOT_WORK_DIRS = {
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshots'),
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshots'),
};
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshot.heapsnapshot'),
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshot.heapsnapshot'),
};
export function summarizeSamples(samples: MemoryReport['samples']) {
const summary = {} as MemoryReport['summary'];
for (const phase of memoryPhases) {
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] = median(values);
}
const heapSnapshot = summarizeHeapSnapshotDataSamples(
samples,
sample => sample.phases[phase].heapSnapshot,
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
);
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
}
return summary;
}
type GenSampleOptions = {
collectHeapSnapshot?: boolean;
heapSnapshotSavePath?: string;
};
async function genSample(label: string, repoDir: string, round: number, options: GenSampleOptions = {}) {
process.stderr.write(`[${label}] Resetting database and Redis\n`);
await resetState();
process.stderr.write(`[${label}] Running migrations\n`);
// 出力はログとして流しつつ手元にも残す (失敗時にexecaが例外メッセージへ含めてくれる)
await execa('pnpm', ['--filter', 'backend', 'migrate'], {
cwd: repoDir,
stdout: ['pipe', process.stderr],
stderr: ['pipe', process.stderr],
});
process.stderr.write(`[${label}] Measuring memory\n`);
return await measureBackendMemory(resolve(repoDir, 'packages/backend'), {
// warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない
...(round <= 0 || options.collectHeapSnapshot === false ? { heapSnapshot: false } : {}),
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
});
}
function heapSnapshotPath(label: HeapSnapshotLabel, round: number) {
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
}
async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const round = selectRepresentativeHeapSnapshotRound(
samples,
summary.afterGc.heapSnapshot?.categories.total,
sample => sample.phases.afterGc.heapSnapshot?.categories.total,
);
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 });
}
/**
* base / head JSONレポートを書き出す
*
*/
export async function compareBackendMemory(options: CompareOptions) {
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const heapSnapshotsEnabled = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
const requestedHeapSnapshotRounds = heapSnapshotsEnabled
? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_ROUNDS', rounds, 1)
: 0;
const heapSnapshotRounds = clampHeapSnapshotRounds(rounds, requestedHeapSnapshotRounds);
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: options.baseDir,
samples: [] as MemoryReport['samples'],
},
head: {
dir: options.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 collectHeapSnapshot = shouldCollectHeapSnapshot(round, rounds, heapSnapshotRounds);
const sample = await genSample(label, reports[label].dir, round, {
collectHeapSnapshot,
...(collectHeapSnapshot ? { heapSnapshotSavePath: heapSnapshotPath(label, round) } : {}),
});
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: MemoryReport = {
timestamp: new Date().toISOString(),
sampleCount: reports[label].samples.length,
aggregation: 'median',
comparison: {
strategy: 'interleaved-pairs',
rounds,
warmupRounds,
heapSnapshotRounds,
startedAt,
},
summary: summaries[label],
samples: reports[label].samples,
};
await writeFile(label === 'base' ? options.baseOutput : options.headOutput, `${JSON.stringify(report, null, 2)}\n`);
}
}
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Redis from 'ioredis';
import pg from 'pg';
/**
* DBを作り直しRedisを空にする
* CIのテスト用サービス (.github/misskey/test.yml)
*/
export async function resetState() {
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();
}
}
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function clampHeapSnapshotRounds(totalRounds: number, requestedRounds: number) {
return Math.min(totalRounds, requestedRounds);
}
export function shouldCollectHeapSnapshot(round: number, totalRounds: number, requestedRounds: number) {
const snapshotRounds = clampHeapSnapshotRounds(totalRounds, requestedRounds);
return round > totalRounds - snapshotRounds;
}
/**
*
*/
export function selectRepresentativeHeapSnapshotRound<T extends { round: number }>(
samples: T[],
medianTotal: number | null | undefined,
getTotal: (sample: T) => number | null | undefined,
) {
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
let selected: { round: number; distance: number } | null = null;
for (const sample of samples) {
const total = getTotal(sample);
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;
}
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resolve } from 'node:path';
import { compareBackendMemory } from './compare';
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
console.error('Usage: inspect <baseDir> <headDir> <baseOutput> <headOutput>');
process.exit(1);
}
try {
await compareBackendMemory({
baseDir: resolve(baseDirArg),
headDir: resolve(headDirArg),
baseOutput: resolve(baseOutputArg),
headOutput: resolve(headOutputArg),
});
} catch (err) {
console.error(err);
process.exit(1);
}
@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resolve } from 'node:path';
import { readOptionalEnv } from 'diagnostics-shared/env';
import { measureBackendMemory } from './measure';
// ローカルデバッグ用: バックエンド1回分の計測結果をJSONで出力する
const [backendDirArg] = process.argv.slice(2);
if (backendDirArg == null) {
console.error('Usage: measure <backendDir>');
process.exit(1);
}
try {
const sample = await measureBackendMemory(resolve(backendDirArg), {
heapSnapshotSavePath: readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH'),
});
console.log(JSON.stringify(sample, null, 2));
} catch (err) {
console.error(JSON.stringify({
error: err instanceof Error ? err.message : String(err),
timestamp: new Date().toISOString(),
}));
process.exit(1);
}
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { readBooleanEnv, readIntegerEnv } from 'diagnostics-shared/env';
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
import { getMemoryUsage, getSmapsRollupMemoryUsage } from './proc';
import {
forkBackendServer,
getRuntimeMemoryUsage,
requestHeapSnapshot,
shutdownBackendServer,
triggerGc,
waitForServerReady,
} from './server';
import { measureMemoryUntilStable } from './stability';
import type { MemorySample } from '../types';
export type MeasureBackendMemoryOptions = {
/** heap snapshotを取得するか (既定: MK_MEMORY_HEAP_SNAPSHOT) */
heapSnapshot?: boolean;
/** 取得したheap snapshotの保存先。未指定なら解析後に破棄する */
heapSnapshotSavePath?: string | null;
heapSnapshotBreakdownTopN?: number;
heapSnapshotTimeoutMs?: number;
startupTimeoutMs?: number;
ipcTimeoutMs?: number;
};
function resolveOptions(options: MeasureBackendMemoryOptions) {
return {
heapSnapshot: options.heapSnapshot ?? readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false),
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
heapSnapshotBreakdownTopN: options.heapSnapshotBreakdownTopN ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1),
heapSnapshotTimeoutMs: options.heapSnapshotTimeoutMs ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1),
startupTimeoutMs: options.startupTimeoutMs ?? readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1),
ipcTimeoutMs: options.ipcTimeoutMs ?? readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1),
};
}
/**
* 1GC後のメモリ使用量を計測して1サンプル分の結果を返す
*/
export async function measureBackendMemory(backendDir: string, options: MeasureBackendMemoryOptions = {}): Promise<MemorySample> {
const settings = resolveOptions(options);
const serverProcess = forkBackendServer(backendDir);
// 起動完了メッセージを取りこぼさないよう、他のハンドラより先に待ち受ける
const serverReady = waitForServerReady(serverProcess, settings.startupTimeoutMs);
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 {
const startupStartTime = Date.now();
await serverReady;
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
await triggerGc(serverProcess, settings.ipcTimeoutMs);
const pid = serverProcess.pid!;
const stableSmapsRollup = await measureMemoryUntilStable(() => getSmapsRollupMemoryUsage(pid));
const afterGc = {
memoryUsage: {
...await getMemoryUsage(pid),
...stableSmapsRollup.memoryUsage,
...await getRuntimeMemoryUsage(serverProcess, settings.ipcTimeoutMs),
},
stability: stableSmapsRollup.stability,
};
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, settings);
return {
timestamp: new Date().toISOString(),
phases: {
afterGc: {
memoryUsage: afterGc.memoryUsage,
memoryStability: afterGc.stability,
heapSnapshot: heapSnapshotAfterGc,
},
},
};
} finally {
await shutdownBackendServer(serverProcess);
}
}
async function getHeapSnapshotStatistics(
serverProcess: ReturnType<typeof forkBackendServer>,
settings: ReturnType<typeof resolveOptions>,
): Promise<HeapSnapshotData | null> {
if (!settings.heapSnapshot) return null;
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
const writtenPath = await requestHeapSnapshot(serverProcess, snapshotPath, settings.heapSnapshotTimeoutMs);
try {
if (settings.heapSnapshotSavePath != null && settings.heapSnapshotSavePath !== '') {
await fs.mkdir(dirname(settings.heapSnapshotSavePath), { recursive: true });
await fs.copyFile(writtenPath, settings.heapSnapshotSavePath);
}
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
return analyzeHeapSnapshot(snapshot, { breakdownTopN: settings.heapSnapshotBreakdownTopN });
} finally {
// 数百MBになることがあるため、解析後は必ず消す
await fs.unlink(writtenPath).catch(err => {
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
});
}
}
@@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs/promises';
export const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
export const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
/**
* `/proc` `Key: 1234 kB`
* 1
*/
export 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 {
throw new Error(`Failed to parse ${key} from ${path}`);
}
}
return result;
}
export function bytesToKiB(value: number) {
return Math.round(value / 1024);
}
export async function getMemoryUsage(pid: number) {
const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8');
return parseMemoryFile(status, procStatusKeys, path);
}
export async function getSmapsRollupMemoryUsage(pid: number) {
const path = `/proc/${pid}/smaps_rollup`;
const smapsRollup = await fs.readFile(path, 'utf-8');
return parseMemoryFile(smapsRollup, smapsRollupKeys, path);
}
@@ -0,0 +1,199 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { fork, type ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import { bytesToKiB } from './proc';
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;
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';
}
export function waitForMessage<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout: number) {
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
globalThis.clearTimeout(timer);
serverProcess.off('message', onMessage);
serverProcess.off('exit', onExit);
serverProcess.off('error', onError);
serverProcess.off('disconnect', onDisconnect);
};
const timer = globalThis.setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ${description}`));
}, timeout);
const onMessage = (message: unknown) => {
if (!predicate(message)) return;
cleanup();
resolve(message);
};
// 子が死んだ場合、待ち続けてもメッセージは来ない。
// タイムアウトまで待って誤解を招くエラーを出すより、理由を添えて即座に失敗させる
const onExit = (code: number | null, signal: string | null) => {
cleanup();
reject(new Error(`Server exited (code=${code}, signal=${signal}) while waiting for ${description}`));
};
const onError = (err: Error) => {
cleanup();
reject(new Error(`Server errored while waiting for ${description}: ${err.message}`));
};
const onDisconnect = () => {
cleanup();
reject(new Error(`Server IPC channel closed while waiting for ${description}`));
};
serverProcess.on('message', onMessage);
serverProcess.once('exit', onExit);
serverProcess.once('error', onError);
serverProcess.once('disconnect', onDisconnect);
});
}
/**
*
* execArgv `--expose-gc` のみを渡す: 親は tsx
* TSローダーが載ってしまいメモリ量が歪む
*/
export function forkBackendServer(backendDir: string) {
return fork(join(backendDir, 'built/entry.js'), [], {
cwd: backendDir,
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_ONLY_SERVER: '1',
MK_NO_DAEMONS: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: ['--expose-gc'],
});
}
export function waitForServerReady(serverProcess: ChildProcess, timeout: number) {
return waitForMessage(
serverProcess,
(message): message is 'ok' => message === 'ok',
'server startup',
timeout,
);
}
export async function triggerGc(serverProcess: ChildProcess, timeout: number) {
// 送信前にlistenerを張らないと、応答を取りこぼす可能性がある
const ok = waitForMessage(serverProcess, isGcMessage, 'GC completion', timeout);
serverProcess.send('gc');
const message = await ok;
if (message === 'gc unavailable') {
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
}
}
export async function getRuntimeMemoryUsage(serverProcess: ChildProcess, timeout: number) {
const response = waitForMessage(
serverProcess,
isRuntimeMemoryUsageMessage,
'memory usage',
timeout,
);
serverProcess.send('memory usage');
const message = await response;
const memoryUsage = message.value;
// /proc 由来の値と単位を揃える
return {
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
External: bytesToKiB(memoryUsage.external),
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
};
}
/**
* heap snapshotの書き出しを依頼し
*/
export async function requestHeapSnapshot(serverProcess: ChildProcess, snapshotPath: string, timeout: number) {
const response = waitForMessage(
serverProcess,
isHeapSnapshotResponseMessage,
'heap snapshot',
timeout,
);
serverProcess.send({
type: 'heap snapshot',
path: snapshotPath,
});
const message = await response;
if (message.type === 'heap snapshot error') {
throw new Error(`Failed to write heap snapshot: ${message.message}`);
}
return typeof message.path === 'string' ? message.path : snapshotPath;
}
/**
* SIGTERMで終了を促しSIGKILLする
*/
export async function shutdownBackendServer(serverProcess: ChildProcess) {
// 既に終了しているなら 'exit' はもう発火しないので、待つと無駄に10秒止まる
if (serverProcess.exitCode != null || serverProcess.signalCode != null) return;
await new Promise<void>(resolve => {
let forceTimer: NodeJS.Timeout | undefined;
const termTimer = globalThis.setTimeout(() => {
serverProcess.kill('SIGKILL');
// SIGKILLは無視できないので通常はここで 'exit' が来る。
// D状態などで落ちない場合に計測全体を止めないよう、待ち時間には上限を設ける
forceTimer = globalThis.setTimeout(resolve, 5000);
}, 10000);
serverProcess.once('exit', () => {
globalThis.clearTimeout(termTimer);
if (forceTimer != null) globalThis.clearTimeout(forceTimer);
resolve();
});
serverProcess.kill('SIGTERM');
});
}
@@ -38,6 +38,10 @@ function getMaxAbsoluteSlopes<T extends Record<string, number>>(readings: { elap
return result;
}
/**
* 使
* `windowSize`
*/
export async function measureMemoryUntilStable<T extends Record<string, number>>(
readMemoryUsage: () => Promise<T>,
timer: MemoryStabilityTimer = defaultTimer,
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { readRequiredEnv } from 'diagnostics-shared/env';
import { renderMemoryReportMarkdown } from './report/markdown';
import type { MemoryReport } from './types';
async function main() {
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
throw new Error('Usage: render-md <baseReport.json> <headReport.json> <output.md>');
}
const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as MemoryReport;
const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as MemoryReport;
await writeFile(resolve(outputFileArg), renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE'),
headHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD'),
}));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,174 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatKiBAsMb } from 'diagnostics-shared/format';
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table';
import {
independentDeltaSummary,
isOutsideObservedNoise,
type IndependentDeltaSummary,
} from 'diagnostics-shared/stats';
import type { MemoryPhase, MemoryReport } from '../types';
export type RenderMemoryReportOptions = {
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
};
const memoryReportPhases = [
{
key: 'afterGc',
title: 'After GC',
},
] as const satisfies readonly { key: MemoryPhase; title: string }[];
const memoryMetrics = [
'HeapUsed',
'Pss',
'USS',
'External',
] as const;
type MemoryMetric = typeof memoryMetrics[number];
const memoryColorThresholdKiB = 100;
function formatMemoryMetricName(metric: MemoryMetric) {
return metric === 'Pss' ? 'PSS' : metric;
}
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: MemoryPhase, metric: MemoryMetric) {
const memoryUsage = sample.phases[phase].memoryUsage;
// USSは直接取れないのでPrivateの合算で近似する
if (metric !== 'USS') return memoryUsage[metric];
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
}
function summarizeMemoryMetric(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return independentDeltaSummary(
base.samples,
head.samples,
sample => getMemoryValueFromSample(sample, phase, metric),
);
}
function getDeltaPercent(summary: IndependentDeltaSummary) {
if (summary.baseMedian === 0) return null;
return summary.delta * 100 / summary.baseMedian;
}
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) {
return renderMetricComparisonTable(
base.samples,
head.samples,
memoryMetrics.map(metric => ({
label: `**${formatMemoryMetricName(metric)}**`,
getValue: sample => getMemoryValueFromSample(sample, phase, metric),
formatValue: formatKiBAsMb,
absoluteThreshold: memoryColorThresholdKiB,
})),
{ onlySignificantChanges: true },
);
}
function toHeapSnapshotReport(report: MemoryReport): HeapSnapshotReport | null {
const summary = report.summary.afterGc.heapSnapshot;
if (summary == null) return null;
return {
summary,
samples: report.samples.flatMap(sample => {
const data = sample.phases.afterGc.heapSnapshot;
return data == null ? [] : [{ round: sample.round, data }];
}),
};
}
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const baseHeapSnapshotReport = toHeapSnapshotReport(base);
const headHeapSnapshotReport = toHeapSnapshotReport(head);
if (baseHeapSnapshotReport == null || headHeapSnapshotReport == null) return null;
const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
const lines = [
'### V8 Heap Snapshot Statistics',
'',
table,
'',
];
// Sankeyはノイズが多く読み取りづらかったため現在は無効。復活させる余地を残して残置する
for (const graph of [
//renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
//renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
] as (string | null)[]) {
if (graph == null) continue;
lines.push(graph);
lines.push('');
}
return lines.join('\n');
}
function countNonConvergedMemorySamples(base: MemoryReport, head: MemoryReport) {
return [base, head]
.flatMap(report => report.samples)
.filter(sample => memoryReportPhases.some(phase => !sample.phases[phase.key].memoryStability.converged))
.length;
}
export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) {
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('');
}
lines.push('');
const nonConvergedSamples = countNonConvergedMemorySamples(base, head);
if (nonConvergedSamples > 0) {
const noun = nonConvergedSamples === 1 ? 'sample' : 'samples';
lines.push(`⚠️ **Measurement warning**: ${nonConvergedSamples} memory ${noun} did not converge.`);
lines.push('');
}
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
if (heapSnapshotSection != null) {
lines.push(heapSnapshotSection);
lines.push('');
}
lines.push(`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`);
lines.push('');
const warningMetric = 'Pss';
const warningSummary = summarizeMemoryMetric(base, head, 'afterGc', warningMetric);
const warningDiffPercent = getDeltaPercent(warningSummary);
if (
warningSummary.delta > 0 &&
warningDiffPercent != null &&
warningDiffPercent > 5 &&
isOutsideObservedNoise(warningSummary)
) {
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('');
}
return `${lines.join('\n')}\n`;
}
@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
/** 計測フェーズ。将来的に増やせるようリスト化してある */
export const memoryPhases = ['afterGc'] as const;
export type MemoryPhase = typeof memoryPhases[number];
export type MemoryStability = {
converged: boolean;
readingCount: number;
elapsedMs: number;
maxAbsoluteSlopesKiBPerSecond: Record<string, number> | null;
};
/** バックエンドを1回起動して得られる計測結果 */
export type MemorySample = {
timestamp: string;
phases: Record<MemoryPhase, {
/** /proc 由来の値はKiB、ランタイム由来の値もKiBに揃えてある */
memoryUsage: Record<string, number>;
memoryStability: MemoryStability;
heapSnapshot: HeapSnapshotData | null;
}>;
};
/** base / head それぞれについて出力されるJSONレポート */
export type MemoryReport = {
timestamp: string;
sampleCount: number;
aggregation: string;
comparison?: {
strategy: string;
rounds: number;
warmupRounds: number;
heapSnapshotRounds?: number;
startedAt: string;
};
summary: Record<MemoryPhase, {
memoryUsage: Record<string, number>;
heapSnapshot?: HeapSnapshotData;
}>;
samples: (MemorySample & {
round: number;
})[];
};
@@ -0,0 +1,31 @@
## ⚙️ Backend Diagnostics Report
### Memory: After GC
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 1.4 MB |
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 1.4 MB |
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 1.4 MB |
<small><i>Only metrics showing significant changes are displayed.</i></small>
### V8 Heap Snapshot Statistics
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 283 KB |
| | | | | |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 KB |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
⚠️ **Warning**: Memory usage (PSS) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.
@@ -0,0 +1,200 @@
{
"timestamp": "2026-07-18T07:57:17.653Z",
"sampleCount": 3,
"aggregation": "median",
"comparison": {
"strategy": "interleaved-pairs",
"rounds": 3,
"warmupRounds": 1,
"startedAt": "2026-07-18T07:57:17.653Z"
},
"summary": {
"afterGc": {
"memoryUsage": {
"VmRSS": 202000,
"Pss": 202000,
"Private_Clean": 2020,
"Private_Dirty": 182000,
"HeapUsed": 152000,
"HeapTotal": 170000,
"External": 8200,
"ArrayBuffers": 2000
},
"heapSnapshot": {
"categories": {
"total": 40400000,
"code": 4747000,
"strings": 4444000,
"jsArrays": 4141000,
"typedArrays": 3838000,
"systemObjects": 3535000,
"otherJsObjects": 3232000,
"otherNonJsObjects": 2929000
},
"nodeCounts": {
"total": 404000,
"code": 47470,
"strings": 44440,
"jsArrays": 41410,
"typedArrays": 38380,
"systemObjects": 35350,
"otherJsObjects": 32320,
"otherNonJsObjects": 29290
},
"breakdowns": {}
}
}
},
"samples": [
{
"round": 1,
"timestamp": "2026-07-18T07:57:17.652Z",
"phases": {
"afterGc": {
"memoryUsage": {
"VmRSS": 201000,
"Pss": 201000,
"Private_Clean": 2010,
"Private_Dirty": 181000,
"HeapUsed": 151000,
"HeapTotal": 170000,
"External": 8100,
"ArrayBuffers": 2000
},
"memoryStability": {
"converged": true,
"readingCount": 3,
"elapsedMs": 4000,
"maxAbsoluteSlopesKiBPerSecond": {
"Pss": 10,
"Private_Dirty": 5
}
},
"heapSnapshot": {
"categories": {
"total": 40200000,
"code": 4723500,
"strings": 4422000,
"jsArrays": 4120500,
"typedArrays": 3819000,
"systemObjects": 3517500,
"otherJsObjects": 3216000,
"otherNonJsObjects": 2914500
},
"nodeCounts": {
"total": 402000,
"code": 47235,
"strings": 44220,
"jsArrays": 41205,
"typedArrays": 38190,
"systemObjects": 35175,
"otherJsObjects": 32160,
"otherNonJsObjects": 29145
},
"breakdowns": {}
}
}
}
},
{
"round": 2,
"timestamp": "2026-07-18T07:57:17.653Z",
"phases": {
"afterGc": {
"memoryUsage": {
"VmRSS": 202000,
"Pss": 202000,
"Private_Clean": 2020,
"Private_Dirty": 182000,
"HeapUsed": 152000,
"HeapTotal": 170000,
"External": 8200,
"ArrayBuffers": 2000
},
"memoryStability": {
"converged": true,
"readingCount": 3,
"elapsedMs": 4000,
"maxAbsoluteSlopesKiBPerSecond": {
"Pss": 10,
"Private_Dirty": 5
}
},
"heapSnapshot": {
"categories": {
"total": 40400000,
"code": 4747000,
"strings": 4444000,
"jsArrays": 4141000,
"typedArrays": 3838000,
"systemObjects": 3535000,
"otherJsObjects": 3232000,
"otherNonJsObjects": 2929000
},
"nodeCounts": {
"total": 404000,
"code": 47470,
"strings": 44440,
"jsArrays": 41410,
"typedArrays": 38380,
"systemObjects": 35350,
"otherJsObjects": 32320,
"otherNonJsObjects": 29290
},
"breakdowns": {}
}
}
}
},
{
"round": 3,
"timestamp": "2026-07-18T07:57:17.653Z",
"phases": {
"afterGc": {
"memoryUsage": {
"VmRSS": 203000,
"Pss": 203000,
"Private_Clean": 2030,
"Private_Dirty": 183000,
"HeapUsed": 153000,
"HeapTotal": 170000,
"External": 8300,
"ArrayBuffers": 2000
},
"memoryStability": {
"converged": true,
"readingCount": 3,
"elapsedMs": 4000,
"maxAbsoluteSlopesKiBPerSecond": {
"Pss": 10,
"Private_Dirty": 5
}
},
"heapSnapshot": {
"categories": {
"total": 40600000,
"code": 4770500,
"strings": 4466000,
"jsArrays": 4161500,
"typedArrays": 3857000,
"systemObjects": 3552500,
"otherJsObjects": 3248000,
"otherNonJsObjects": 2943500
},
"nodeCounts": {
"total": 406000,
"code": 47705,
"strings": 44660,
"jsArrays": 41615,
"typedArrays": 38570,
"systemObjects": 35525,
"otherJsObjects": 32480,
"otherNonJsObjects": 29435
},
"breakdowns": {}
}
}
}
}
]
}
@@ -0,0 +1,200 @@
{
"timestamp": "2026-07-18T07:57:17.654Z",
"sampleCount": 3,
"aggregation": "median",
"comparison": {
"strategy": "interleaved-pairs",
"rounds": 3,
"warmupRounds": 1,
"startedAt": "2026-07-18T07:57:17.654Z"
},
"summary": {
"afterGc": {
"memoryUsage": {
"VmRSS": 218000,
"Pss": 218000,
"Private_Clean": 2020,
"Private_Dirty": 198000,
"HeapUsed": 168000,
"HeapTotal": 186000,
"External": 8200,
"ArrayBuffers": 2000
},
"heapSnapshot": {
"categories": {
"total": 43600000,
"code": 5123000,
"strings": 4796000,
"jsArrays": 4469000,
"typedArrays": 4142000,
"systemObjects": 3815000,
"otherJsObjects": 3488000,
"otherNonJsObjects": 3161000
},
"nodeCounts": {
"total": 436000,
"code": 51230,
"strings": 47960,
"jsArrays": 44690,
"typedArrays": 41420,
"systemObjects": 38150,
"otherJsObjects": 34880,
"otherNonJsObjects": 31610
},
"breakdowns": {}
}
}
},
"samples": [
{
"round": 1,
"timestamp": "2026-07-18T07:57:17.654Z",
"phases": {
"afterGc": {
"memoryUsage": {
"VmRSS": 217000,
"Pss": 217000,
"Private_Clean": 2010,
"Private_Dirty": 197000,
"HeapUsed": 167000,
"HeapTotal": 186000,
"External": 8100,
"ArrayBuffers": 2000
},
"memoryStability": {
"converged": true,
"readingCount": 3,
"elapsedMs": 4000,
"maxAbsoluteSlopesKiBPerSecond": {
"Pss": 10,
"Private_Dirty": 5
}
},
"heapSnapshot": {
"categories": {
"total": 43400000,
"code": 5099500,
"strings": 4774000,
"jsArrays": 4448500,
"typedArrays": 4123000,
"systemObjects": 3797500,
"otherJsObjects": 3472000,
"otherNonJsObjects": 3146500
},
"nodeCounts": {
"total": 434000,
"code": 50995,
"strings": 47740,
"jsArrays": 44485,
"typedArrays": 41230,
"systemObjects": 37975,
"otherJsObjects": 34720,
"otherNonJsObjects": 31465
},
"breakdowns": {}
}
}
}
},
{
"round": 2,
"timestamp": "2026-07-18T07:57:17.654Z",
"phases": {
"afterGc": {
"memoryUsage": {
"VmRSS": 218000,
"Pss": 218000,
"Private_Clean": 2020,
"Private_Dirty": 198000,
"HeapUsed": 168000,
"HeapTotal": 186000,
"External": 8200,
"ArrayBuffers": 2000
},
"memoryStability": {
"converged": true,
"readingCount": 3,
"elapsedMs": 4000,
"maxAbsoluteSlopesKiBPerSecond": {
"Pss": 10,
"Private_Dirty": 5
}
},
"heapSnapshot": {
"categories": {
"total": 43600000,
"code": 5123000,
"strings": 4796000,
"jsArrays": 4469000,
"typedArrays": 4142000,
"systemObjects": 3815000,
"otherJsObjects": 3488000,
"otherNonJsObjects": 3161000
},
"nodeCounts": {
"total": 436000,
"code": 51230,
"strings": 47960,
"jsArrays": 44690,
"typedArrays": 41420,
"systemObjects": 38150,
"otherJsObjects": 34880,
"otherNonJsObjects": 31610
},
"breakdowns": {}
}
}
}
},
{
"round": 3,
"timestamp": "2026-07-18T07:57:17.654Z",
"phases": {
"afterGc": {
"memoryUsage": {
"VmRSS": 219000,
"Pss": 219000,
"Private_Clean": 2030,
"Private_Dirty": 199000,
"HeapUsed": 169000,
"HeapTotal": 186000,
"External": 8300,
"ArrayBuffers": 2000
},
"memoryStability": {
"converged": true,
"readingCount": 3,
"elapsedMs": 4000,
"maxAbsoluteSlopesKiBPerSecond": {
"Pss": 10,
"Private_Dirty": 5
}
},
"heapSnapshot": {
"categories": {
"total": 43800000,
"code": 5146500,
"strings": 4818000,
"jsArrays": 4489500,
"typedArrays": 4161000,
"systemObjects": 3832500,
"otherJsObjects": 3504000,
"otherNonJsObjects": 3175500
},
"nodeCounts": {
"total": 438000,
"code": 51465,
"strings": 48180,
"jsArrays": 44895,
"typedArrays": 41610,
"systemObjects": 38325,
"otherJsObjects": 35040,
"otherNonJsObjects": 31755
},
"breakdowns": {}
}
}
}
}
]
}
@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import {
clampHeapSnapshotRounds,
selectRepresentativeHeapSnapshotRound,
shouldCollectHeapSnapshot,
} from '../src/heap-snapshot-sampling';
describe('heap snapshot sampling', () => {
test('collects only the final requested rounds', () => {
const selected = Array.from({ length: 15 }, (_, index) => index + 1)
.filter(round => shouldCollectHeapSnapshot(round, 15, 3));
expect(selected).toStrictEqual([13, 14, 15]);
});
test('clamps the requested count to the measured round count', () => {
expect(clampHeapSnapshotRounds(5, 20)).toBe(5);
expect(Array.from({ length: 5 }, (_, index) => index + 1)
.filter(round => shouldCollectHeapSnapshot(round, 5, 20)))
.toStrictEqual([1, 2, 3, 4, 5]);
});
test('collects no snapshots when the effective count is zero', () => {
expect(Array.from({ length: 5 }, (_, index) => index + 1)
.filter(round => shouldCollectHeapSnapshot(round, 5, 0)))
.toStrictEqual([]);
});
test('selects the snapshot nearest the median and ignores missing rounds', () => {
const samples = [
{ round: 12, total: null },
{ round: 13, total: 100 },
{ round: 14, total: 110 },
{ round: 15, total: 130 },
];
expect(selectRepresentativeHeapSnapshotRound(samples, 110, sample => sample.total)).toBe(14);
});
});
@@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { pairedDeltaSummary } from 'diagnostics-shared/stats';
import { renderMemoryReportMarkdown } from '../src/report/markdown';
import type { MemoryReport } from '../src/types';
const fixturesDir = join(import.meta.dirname, 'fixtures');
async function loadFixture(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport;
}
function replacePssSamples(report: MemoryReport, values: number[]) {
const templates = structuredClone(report.samples);
report.samples = values.map((Pss, index) => {
const sample = structuredClone(templates[index % templates.length]);
sample.round = index + 1;
sample.phases.afterGc.memoryUsage.Pss = Pss;
const privateClean = sample.phases.afterGc.memoryUsage.Private_Clean;
sample.phases.afterGc.memoryUsage.Private_Dirty = Pss - privateClean;
return sample;
});
report.sampleCount = report.samples.length;
return report;
}
function findMetricRow(markdown: string, metric: string) {
const row = markdown.split('\n').find(line => line.startsWith(`| **${metric}**`));
if (row === undefined) throw new Error(`expected memory report to contain a ${metric} row`);
return row;
}
/**
*
* `vitest -u` __snapshots__
*/
test('renders the backend memory report', async () => {
const markdown = renderMemoryReportMarkdown(await loadFixture('base'), await loadFixture('head'), {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
});
test('throws when filtering leaves fewer than two heap snapshots per side', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
for (const report of [base, head]) {
for (const sample of report.samples.slice(0, -1)) {
sample.phases.afterGc.heapSnapshot = null;
}
}
expect(() => renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
})).toThrow('At least two samples per side are required');
});
test('omits a paired-looking PSS delta that stays within observed noise', async () => {
const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]);
const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]);
expect(pairedDeltaSummary(
base.samples,
head.samples,
sample => sample.phases.afterGc.memoryUsage.Pss,
).median).toBe(2_900);
const markdown = renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
expect(markdown).not.toContain('| **PSS** |');
expect(markdown).toContain('<small><i>Only metrics showing significant changes are displayed.</i></small>');
expect(markdown).not.toContain('⚠️ **Warning**: Memory usage (PSS)');
});
test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
base.samples[0].phases.afterGc.memoryStability.converged = false;
const markdown = renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
const memorySection = markdown.slice(0, markdown.indexOf('### V8 Heap Snapshot Statistics'));
expect(memorySection).not.toContain('inconclusive');
expect(findMetricRow(markdown, 'PSS')).toContain('\\color{orange}');
expect(markdown).toContain('⚠️ **Measurement warning**: 1 memory sample did not converge.');
expect(markdown).not.toContain('results are marked inconclusive');
expect(markdown).toContain('⚠️ **Warning**: Memory usage (PSS)');
});
test('throws for an undersampled memory comparison', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
base.samples = base.samples.slice(0, 1);
head.samples = head.samples.slice(0, 1);
expect(() => renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
})).toThrow('At least two samples per side are required');
});
test('renders an unavailable percentage when the base median is zero', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
for (const sample of base.samples) sample.phases.afterGc.memoryUsage.External = 0;
const markdown = renderMemoryReportMarkdown(base, head, {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
expect(findMetricRow(markdown, 'External')).toContain('<br>-');
expect(markdown).toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD | Result |');
});
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter } from 'node:events';
import type { ChildProcess } from 'node:child_process';
import { describe, expect, test } from 'vitest';
import { waitForMessage } from '../src/measure/server';
/** waitForMessage が使うのは message/exit/error/disconnect の購読だけなので EventEmitter で足りる */
function createFakeServer() {
return new EventEmitter() as unknown as ChildProcess;
}
function isPing(message: unknown): message is 'ping' {
return message === 'ping';
}
describe('waitForMessage', () => {
test('resolves with the first matching message', async () => {
const server = createFakeServer();
const received = waitForMessage(server, isPing, 'ping', 1_000);
server.emit('message', 'noise');
server.emit('message', 'ping');
await expect(received).resolves.toBe('ping');
});
// 子が死んだあとメッセージは来ないので、タイムアウトまで待たずに理由を添えて失敗させる
test('rejects immediately when the server exits', async () => {
const server = createFakeServer();
const received = waitForMessage(server, isPing, 'ping', 60_000);
server.emit('exit', 1, null);
await expect(received).rejects.toThrow(/Server exited \(code=1, signal=null\) while waiting for ping/);
});
test('rejects immediately when the server errors', async () => {
const server = createFakeServer();
const received = waitForMessage(server, isPing, 'ping', 60_000);
server.emit('error', new Error('spawn failed'));
await expect(received).rejects.toThrow(/spawn failed/);
});
test('rejects immediately when the IPC channel closes', async () => {
const server = createFakeServer();
const received = waitForMessage(server, isPing, 'ping', 60_000);
server.emit('disconnect');
await expect(received).rejects.toThrow(/IPC channel closed/);
});
test('rejects on timeout', async () => {
const server = createFakeServer();
await expect(waitForMessage(server, isPing, 'ping', 1)).rejects.toThrow(/Timed out waiting for ping/);
});
// 待機が終わった後もリスナーが残っていると、ラウンドを重ねるごとに積み上がる
test('removes every listener once settled', async () => {
const server = createFakeServer();
const emitter = server as unknown as EventEmitter;
const received = waitForMessage(server, isPing, 'ping', 1_000);
server.emit('message', 'ping');
await received;
for (const event of ['message', 'exit', 'error', 'disconnect']) {
expect(emitter.listenerCount(event)).toBe(0);
}
});
});
@@ -3,9 +3,8 @@
* 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';
import { expect, test } from 'vitest';
import { measureMemoryUntilStable } from '../src/measure/stability';
function createTimer() {
let elapsedMs = 0;
@@ -35,9 +34,9 @@ test('adopts the latest reading once Pss and Private_Dirty slopes converge', asy
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 3);
assert.deepEqual(result.memoryUsage, readings[2]);
assert.deepEqual(result.stability, {
expect(readCount).toBe(3);
expect(result.memoryUsage).toStrictEqual(readings[2]);
expect(result.stability).toStrictEqual({
converged: true,
readingCount: 3,
elapsedMs: 4000,
@@ -58,9 +57,9 @@ test('uses only the latest readings when determining convergence', async () => {
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 5);
assert.equal(result.stability.converged, true);
assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, {
expect(readCount).toBe(5);
expect(result.stability.converged).toBe(true);
expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({
Pss: 20,
Private_Dirty: 10,
});
@@ -77,9 +76,9 @@ test('bounds the wait and reports the latest slopes when memory does not converg
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 6);
assert.deepEqual(result.memoryUsage, readings[5]);
assert.deepEqual(result.stability, {
expect(readCount).toBe(6);
expect(result.memoryUsage).toStrictEqual(readings[5]);
expect(result.stability).toStrictEqual({
converged: false,
readingCount: 6,
elapsedMs: 10000,
@@ -101,9 +100,9 @@ test('does not treat opposing adjacent slopes as convergence', async () => {
];
const { result, readCount } = await measure(readings);
assert.equal(readCount, 6);
assert.equal(result.stability.converged, false);
assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, {
expect(readCount).toBe(6);
expect(result.stability.converged).toBe(false);
expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({
Pss: 300,
Private_Dirty: 0,
});
@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": []
}
@@ -0,0 +1,25 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../../packages/shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
ignores: [
'**/node_modules',
'**/__snapshots__',
'test/fixtures',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];
@@ -0,0 +1,25 @@
{
"name": "diagnostics-frontend",
"private": true,
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"inspect-browser": "tsx src/inspect-browser.ts",
"lint": "pnpm typecheck && pnpm eslint",
"render-browser-html": "tsx src/render-browser-html.ts",
"render-md": "tsx src/render-md.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"diagnostics-shared": "workspace:*"
},
"devDependencies": {
"@types/node": "26.1.1",
"frontend": "workspace:*",
"playwright": "1.61.1",
"tsx": "4.23.1",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,211 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { writeFile } from 'node:fs/promises';
import { chromium } from 'playwright';
import { enableNetworkTracking, type NetworkTracker } from './network';
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from './types';
export type HeadlessChromeOptions = {
scenarioTimeoutMs: number;
baseUrl: string;
};
export class HeadlessChromeController {
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 networkTracker: NetworkTracker | null = null;
private constructor(
browser: Browser,
context: BrowserContext,
page: Page,
cdp: CDPSession,
options: HeadlessChromeOptions,
) {
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 => {
const type = message.type();
this.diagnostics.console[type] = (this.diagnostics.console[type] ?? 0) + 1;
});
}
public get networkRequests(): NetworkRequest[] {
return this.networkTracker?.networkRequests ?? [];
}
public get webSocketConnections(): WebSocketConnection[] {
return this.networkTracker?.webSocketConnections ?? [];
}
static async create(label: string, options: HeadlessChromeOptions): Promise<HeadlessChromeController> {
process.stderr.write(`[${label}] Launching Playwright Chromium\n`);
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: HeadlessChromeOptions, 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() {
this.networkTracker = await enableNetworkTracking(this.cdp);
}
public async waitForNetworkDetails() {
await this.networkTracker?.waitForDetails();
}
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('Performance.getMetrics');
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
const runtimeHeap = await this.cdp.send('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[] = [];
const onChunk = (params: { chunk: string }) => {
chunks.push(params.chunk);
};
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
let content: string;
try {
await this.cdp.send('HeapProfiler.enable');
await this.cdp.send('HeapProfiler.collectGarbage');
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
content = chunks.join('');
} finally {
// 外さないとラウンドごとに積み上がり、古い配列にチャンクを流し続ける
this.cdp.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
}
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);
}
}
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { median } from 'diagnostics-shared/stats';
import type { BrowserDiagnostics } from './types';
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select));
return {
pageErrorCount: medianOf(sample => sample.pageErrorCount),
console: {
log: medianOf(sample => sample.console.log),
warning: medianOf(sample => sample.console.warning),
error: medianOf(sample => sample.console.error),
info: medianOf(sample => sample.console.info),
},
};
}
@@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CDPSession } from 'playwright';
import type { NetworkRequest, NetworkSummary, WebSocketConnection } from './types';
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;
// opcode 1 はテキストフレームで、それ以外はbase64エンコードされたバイナリとして届く
if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8');
return Buffer.byteLength(frame.payloadData, 'base64');
}
export type NetworkTracker = {
networkRequests: NetworkRequest[];
webSocketConnections: WebSocketConnection[];
/** CDPへの追加問い合わせ (postDataの取得) が全て決着するまで待つ */
waitForDetails: () => Promise<void>;
};
/**
* CDPのNetworkドメインを有効化し/WebSocketの生ログを収集し続けるトラッカーを返す
*/
export async function enableNetworkTracking(cdp: CDPSession): Promise<NetworkTracker> {
const networkRequests: NetworkRequest[] = [];
const webSocketConnections: WebSocketConnection[] = [];
const requests = new Map<string, NetworkRequest>();
const webSockets = new Map<string, WebSocketConnection>();
const pendingDetailReads: Promise<void>[] = [];
const readRequestBody = (row: NetworkRequest) => {
if (!row.hasRequestBody || row.requestBody != null) return;
const pending = cdp.send('Network.getRequestPostData', {
requestId: row.requestId,
}).then(result => {
row.requestBody = result.postData;
}).catch(() => {
// Some requests expose hasPostData but no longer have retrievable body data.
});
pendingDetailReads.push(pending);
};
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);
networkRequests.push(row);
});
cdp.on('Network.webSocketCreated', params => {
if (params.requestId == null || params.url == null) return;
const row: WebSocketConnection = {
requestId: params.requestId,
url: params.url,
// Network.webSocketCreated はtimestampを持たないので、closedAt との差分は取れない
createdAt: 0,
sentFrameCount: 0,
receivedFrameCount: 0,
sentBytes: 0,
receivedBytes: 0,
errorCount: 0,
};
webSockets.set(params.requestId, row);
webSocketConnections.push(row);
});
cdp.on('Network.webSocketWillSendHandshakeRequest', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers);
});
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);
});
cdp.on('Network.webSocketFrameSent', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.sentFrameCount += 1;
row.sentBytes += webSocketFramePayloadBytes(params.response);
});
cdp.on('Network.webSocketFrameReceived', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.receivedFrameCount += 1;
row.receivedBytes += webSocketFramePayloadBytes(params.response);
});
cdp.on('Network.webSocketFrameError', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.errorCount += 1;
});
cdp.on('Network.webSocketClosed', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.closedAt = params.timestamp ?? 0;
});
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;
});
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;
});
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);
});
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 cdp.send('Network.enable');
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
await cdp.send('Network.setBypassServiceWorker', { bypass: true });
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Performance.enable');
return {
networkRequests,
webSocketConnections,
waitForDetails: async () => {
// 待っている最中にさらにpendingが増えることがあるので、増えなくなるまで繰り返す
let settledCount = 0;
while (settledCount < pendingDetailReads.length) {
const pending = pendingDetailReads.slice(settledCount);
settledCount = pendingDetailReads.length;
await Promise.allSettled(pending);
}
},
};
}
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,
})),
};
}
@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** 差分HTMLは単体のファイルとして配布されるので、CSSも埋め込みで持つ */
export const networkDiffHtmlStyles = ` :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);
}`;
@@ -0,0 +1,255 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatBytes, formatNumber } from 'diagnostics-shared/format';
import { type Raw, html, joinHtml, raw } from 'diagnostics-shared/html';
import { networkDiffHtmlStyles } from './html-styles';
import type { BrowserMeasurementSample, BrowserMetricsReport, NetworkRequest } from '../types';
type DiffDirection = 'added' | 'removed';
type RequestDiff = {
direction: DiffDirection;
round: number;
baseCount: number;
headCount: number;
request: NetworkRequest;
};
function isHttpRequest(request: NetworkRequest) {
try {
const { protocol } = new URL(request.url);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
function requestKey(request: NetworkRequest) {
// URLに現れない文字で区切らないと、区切り文字を含むURLが別のキーと衝突しうる
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]));
}
/**
* (method, resourceType, URL)
* head側の増分を addedbase側の余りを removed
*/
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[]): Raw {
const added = diffs.filter(diff => diff.direction === 'added').length;
const removed = diffs.filter(diff => diff.direction === 'removed').length;
const typeCounts = countBy(diffs, diff => diff.request.resourceType);
const typeRows = joinHtml(typeCounts.map(([type, count]) => html`
<tr>
<td>${type}</td>
<td class="num">${formatNumber(count)}</td>
</tr>`), '');
return html`
<section class="summary">
<div>
<span class="label">Base samples</span>
<strong>${formatNumber(base.sampleCount)}</strong>
</div>
<div>
<span class="label">Head samples</span>
<strong>${formatNumber(head.sampleCount)}</strong>
</div>
<div>
<span class="label">Added in Head</span>
<strong class="added-text">${formatNumber(added)}</strong>
</div>
<div>
<span class="label">Removed in Head</span>
<strong class="removed-text">${formatNumber(removed)}</strong>
</div>
</section>
${typeCounts.length === 0 ? raw('') : html`
<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): Raw {
if (content == null || content === '') return raw('');
return html`
<details${open ? raw(' open') : raw('')}>
<summary>${title}</summary>
<pre>${content}</pre>
</details>`;
}
function renderRequest(diff: RequestDiff): Raw {
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
? html`<p class="empty">Request body was present but could not be retrieved from CDP.</p>`
: raw('');
return html`
<article class="request ${diff.direction}">
<header>
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
<span class="method">${request.method}</span>
<span class="type">${request.resourceType}</span>
<span class="status">${request.status ?? '-'}</span>
</header>
<a class="url" href="${request.url}">${request.url}</a>
<dl>
<div><dt>Round</dt><dd>${formatNumber(diff.round)}</dd></div>
<div><dt>Base count</dt><dd>${formatNumber(diff.baseCount)}</dd></div>
<div><dt>Head count</dt><dd>${formatNumber(diff.headCount)}</dd></div>
<div><dt>Encoded</dt><dd>${formatBytes(request.encodedDataLength ?? 0)}</dd></div>
<div><dt>Decoded body</dt><dd>${formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
<div><dt>MIME</dt><dd>${request.mimeType ?? '-'}</dd></div>
<div><dt>Protocol</dt><dd>${request.protocol ?? '-'}</dd></div>
<div><dt>Remote</dt><dd>${request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`}</dd></div>
<div><dt>Failed</dt><dd>${request.failed ? (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[]): Raw {
const added = diffs.filter(diff => diff.direction === 'added').length;
const removed = diffs.filter(diff => diff.direction === 'removed').length;
return html`
<section>
<h2>Round ${formatNumber(round)}</h2>
<p>${formatNumber(added)} added, ${formatNumber(removed)} removed</p>
<div class="requests">
${joinHtml(diffs.map(renderRequest), '\n')}
</div>
</section>`;
}
export function renderBrowserDiagnosticsHtml(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
? html`<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>`
: joinHtml(rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))), '\n');
return String(html`<!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>
${raw(networkDiffHtmlStyles)}
</style>
</head>
<body>
<main>
<h1>Frontend Browser Network Request Diff</h1>
<p class="meta">Generated at ${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>
`);
}
@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../../packages/frontend/test/e2e/shared';
import { sleep } from './server';
import type { HeadlessChromeController } from './controller';
export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible';
/**
* DBを消して管理者だけ作り直す
*/
export async function prepareInstance(baseUrl: string) {
await resetState(baseUrl);
await registerUser(baseUrl, 'admin', 'admin1234', true);
}
export async function runSignupAndPostScenario(chrome: HeadlessChromeController, baseUrl: string) {
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 sleep(1000);
}
@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
export function sleep(ms: number) {
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
}
function commandName(command: string) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
/**
* Misskey
* POSIXでは detached
*/
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;
}
const serverStartupTimeoutMs = 120_000;
function hasExited(child: ChildProcess) {
return child.exitCode != null || child.signalCode != null;
}
export async function waitForServer(baseUrl: string, child: ChildProcess) {
const startedAt = Date.now();
while (Date.now() - startedAt < serverStartupTimeoutMs) {
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`);
try {
// 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう
const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt);
const response = await fetch(`${baseUrl}/`, {
redirect: 'manual',
signal: AbortSignal.timeout(remainingMs),
});
if (response.status < 500) return;
} catch {
// 中断・接続拒否いずれもまだ起動中とみなしてリトライする
}
await sleep(1_000);
}
throw new Error(`Timed out waiting for ${baseUrl}`);
}
export async function stopServer(child: ChildProcess) {
if (hasExited(child)) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else if (child.pid != null) {
try {
// プロセスグループごと落とさないと pnpm 配下の node が残る
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
await new Promise<void>(resolvePromise => {
if (hasExited(child)) {
resolvePromise();
return;
}
const forceKillTimer = setTimeout(() => {
// 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある
if (!hasExited(child) && 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);
forceKillTimer.unref();
child.once('exit', () => {
clearTimeout(forceKillTimer);
resolvePromise();
});
});
}
@@ -0,0 +1,158 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { finiteMedian } from 'diagnostics-shared/stats';
import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot';
import { summarizeBrowserDiagnostics } from './diagnostics';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types';
export type SummarizeOptions = {
url: string;
heapSnapshotBreakdownTopN: number;
};
/**
* 1
* largestRequests
*/
export function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
const medianValue = finiteMedian(samples.map(getValue), 0);
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), 0),
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes), 0),
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes), 0),
};
}
export 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), 0),
webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount), 0),
webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes), 0),
webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes), 0),
finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount), 0),
failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount), 0),
cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount), 0),
serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount), 0),
totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes), 0),
totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes), 0),
sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes), 0),
thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes), 0),
byResourceType,
largestRequests: representative.network.largestRequests,
failedRequests: representative.network.failedRequests,
};
}
export 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]), 0);
}
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]), 0);
}
return {
cdpMetrics,
runtimeHeap: {
usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize), 0),
totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize), 0),
},
tabMemory: {
totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes), 0),
},
webVitals,
};
}
export function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[], breakdownTopN: number) {
const summary = summarizeHeapSnapshotDataSamples(
samples,
sample => sample.heapSnapshot,
{ breakdownTopN },
);
if (summary == null) throw new Error('No heap snapshot samples');
return summary;
}
export function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[], options: SummarizeOptions): 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: options.url,
scenario: representative.scenario,
diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)),
durationMs: finiteMedian(samples.map(sample => sample.durationMs), 0),
network: summarizeNetworkSamples(samples),
performance: summarizePerformanceSamples(samples),
heapSnapshot: summarizeHeapSnapshotSamples(samples, options.heapSnapshotBreakdownTopN),
};
return {
label,
timestamp: new Date().toISOString(),
url: options.url,
scenario: representative.scenario,
sampleCount: samples.length,
aggregation: 'median',
summary,
samples,
};
}
@@ -0,0 +1,144 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
// NOTE: (AI向け)
// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、
// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、
// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。
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 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;
};
export type BrowserMeasurementSample = BrowserMeasurement & {
round: number;
/** ラウンド単位のリクエスト差分HTMLを描くために生ログを丸ごと保持する */
networkRequests: NetworkRequest[];
};
export type BrowserMetricsReport = {
label: string;
timestamp: string;
url: string;
scenario: string;
sampleCount: number;
aggregation: 'median';
summary: BrowserMeasurement;
samples: BrowserMeasurementSample[];
};
@@ -0,0 +1,218 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
calcAndFormatDeltaBytes,
calcAndFormatDeltaPercentInMdTable,
escapeMdTableCell,
formatBytes,
} from 'diagnostics-shared/format';
import type { CollectedBundleReport, FileEntry } from './manifest';
/**
* `(other)`
*
*/
const smallDeltaThreshold = 5;
/** diff表に個別行として出す上限。これを超えた分は `(other)` に集約する */
const diffRowLimit = 30;
function entryDisplayName(entry: FileEntry | undefined) {
if (entry == null) return '';
return entry.displayName || entry.file;
}
export function getChunkComparisonRows(keys: string[], base: Partial<Record<string, FileEntry>>, head: Partial<Record<string, FileEntry>>) {
return keys.map(key => {
const baseEntry = base[key];
const headEntry = head[key];
const baseSize = baseEntry?.size ?? 0;
const headSize = headEntry?.size ?? 0;
return {
key,
name: entryDisplayName(baseEntry ?? headEntry),
baseFile: baseEntry?.file,
headFile: headEntry?.file,
baseSize,
headSize,
changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged',
sortSize: Math.max(baseSize, headSize),
};
});
}
export type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
export type ChunkAggregate = {
baseSize: number;
headSize: number;
baseCount: number;
headCount: number;
};
export function sumChunkSizes(chunks: FileEntry[]) {
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
}
/**
* (= base/head )
*/
export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate {
const baseGenerated = base.filter(chunk => chunk.comparisonKey == null);
const headGenerated = head.filter(chunk => chunk.comparisonKey == null);
return {
baseSize: sumChunkSizes(baseGenerated),
headSize: sumChunkSizes(headGenerated),
baseCount: baseGenerated.length,
headCount: headGenerated.length,
};
}
export function hasSmallDelta(row: ChunkComparisonRow) {
return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold;
}
export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
return {
baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0),
headSize: rows.reduce((sum, row) => sum + row.headSize, 0),
baseCount: rows.filter(row => row.baseFile != null).length,
headCount: rows.filter(row => row.headFile != null).length,
};
}
export 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);
}
export function summarizeChunkChanges(rows: ChunkComparisonRow[]) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
removed: rows.filter((row) => row.changeType === 'removed').length,
};
}
export function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
/**
*
*/
export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
return Math.abs(b.headSize - b.baseSize) - Math.abs(a.headSize - a.baseSize)
|| (b.headSize - b.baseSize) - (a.headSize - a.baseSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
export function chunkFileDisplay(row: ChunkComparisonRow) {
if (row.baseFile == null) return row.headFile ?? '';
if (row.headFile == null || row.baseFile === row.headFile) return row.baseFile;
return `${row.baseFile}${row.headFile}`;
}
export function chunkMarkdownTable(
rows: ChunkComparisonRow[],
total?: { baseSize: number; headSize: number },
generated?: ChunkAggregate,
other?: ChunkAggregate,
) {
const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0);
const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0);
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [
'| Chunk | Base | Head | Δ | Δ (%) |', // nbspにすること
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
const chunkFile = chunkFileDisplay(row);
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`);
}
}
if (hasGenerated) {
// eslint-disable-next-line no-irregular-whitespace
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること
}
if (hasOther) {
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
}
return lines.join('\n');
}
export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) {
const baseComparable = base.comparableChunks;
const headComparable = head.comparableChunks;
const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])];
const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
baseSize: sumChunkSizes(base.chunks),
headSize: sumChunkSizes(head.chunks),
};
const diffGenerated = generatedAggregate(base.chunks, head.chunks);
const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const diffRows = largeDeltaRows.slice(0, diffRowLimit);
// 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる
const diffOther = comparisonRowsAggregate([
...changedRows.filter(hasSmallDelta),
...largeDeltaRows.slice(diffRowLimit),
]);
const baseStartupFiles = new Set(base.startupFiles);
const headStartupFiles = new Set(head.startupFiles);
const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file));
const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file));
const baseStartupComparable = comparableMap(baseStartupChunks);
const headStartupComparable = comparableMap(headStartupChunks);
const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])];
const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const startupTotal = {
baseSize: sumChunkSizes(baseStartupChunks),
headSize: sumChunkSizes(headStartupChunks),
};
const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks);
return [
'<details>',
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
'',
'_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._',
'',
'</details>',
'',
].join('\n');
}
@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
/**
* Windows `\` 区切りを `/` に揃える。manifest 側のキーが常に `/` 区切りのため、
*
*/
export function normalizePath(filePath: string) {
return filePath.split(path.sep).join('/');
}
export async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}
export async function fileSize(filePath: string) {
const stat = await fs.stat(filePath);
return stat.size;
}
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* traverseDirectory(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
@@ -0,0 +1,180 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils';
export type BundleManifestChunk = {
file: string;
src?: string;
name?: string;
isEntry?: boolean;
imports?: string[];
};
export type BundleManifest = Record<string, BundleManifestChunk>;
/**
*
* ja-JP
*/
const locale = 'ja-JP';
/**
* `src`
*/
const stableNamedChunks = new Set(['vue', 'i18n']);
export type FileEntry = {
comparisonKey: string | null;
displayName: string;
file: string;
manifestKeys: string[];
size: number;
};
export type CollectedBundleReport = {
manifest: BundleManifest;
chunks: FileEntry[];
comparableChunks: Record<string, FileEntry>;
chunksByManifestKey: Record<string, FileEntry>;
startupFiles: string[];
};
export function findEntryKey(manifest: BundleManifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
?? null;
}
/**
*
* base/head
*/
export function stableChunkKey(chunk: BundleManifestChunk) {
if (chunk.src != null) return `src:${normalizePath(chunk.src)}`;
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
return null;
}
/**
* (entry import) manifest
*/
export function collectStartupManifestKeys(manifest: BundleManifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<string>();
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
function visit(key: string, importedBy?: string) {
if (keys.has(key)) return;
const chunk = manifest[key];
const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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);
return keys;
}
/**
* manifest `scripts/`
*
*/
export async function resolveBuiltFile(outDir: string, file: string) {
if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile);
if (await fileExists(localizedPath)) {
return {
absolutePath: localizedPath,
relativePath: `${locale}/${localizedFile}`,
};
}
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
}
return {
absolutePath: path.join(outDir, file),
relativePath: file,
};
}
export async function collectBundleReport(repoDir: string): Promise<CollectedBundleReport> {
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 BundleManifest;
const chunksByFile = new Map<string, FileEntry>();
const comparableChunks = new Map<string, FileEntry>();
const chunksByManifestKey = new Map<string, FileEntry>();
for (const [manifestKey, chunk] of Object.entries(manifest)) {
if (!chunk.file.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file);
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 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);
}
}
// manifest に載らないロケール別チャンクも合計サイズには含めたいので拾っておく
const localeDir = path.join(outDir, locale);
if (await fileExists(localeDir)) {
for await (const fullPath of traverseDirectory(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath));
if (chunksByFile.has(relativePath)) continue;
chunksByFile.set(relativePath, {
comparisonKey: null,
displayName: relativePath,
file: relativePath,
manifestKeys: [],
size: await 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: [...chunksByFile.values()],
comparableChunks: Object.fromEntries(comparableChunks),
chunksByManifestKey: Object.fromEntries(chunksByManifestKey),
startupFiles: [...startupFiles],
};
}

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