Compare commits

...

20 Commits

Author SHA1 Message Date
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
68 changed files with 1523 additions and 302 deletions
+6
View File
@@ -20,9 +20,15 @@ reviews:
instructions: |
【レビュー対象外】
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。
【特別な対応を要するレビュー】
- プルリクエストで変更されていない部分において、プルリクエストのタイトルや説明文に照らしてスコープ外であるが修正すべき点を見つけた場合は、このプルリクエストで修正させるのではなく、別のプルリクエストを開いてそれを修正するように指示してください。\
なお、当該プルリクエストで行が追加/削除されたことによりスコープ外の問題が起こりうる場合は、例外的にこのプルリクエストで修正させてください。
【注力してほしい観点】
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
- プルリクエストのタイトルや説明文に照らしてスコープ外の変更が含まれていないかをレビューしてください。なお、明らかにスコープ外であると確信できるもの(例: 全く関係ないファイルを編集している等)のみを対象とし、スコープ外かどうかの判断に困る場合は指摘しないでください。\
スコープ外の変更が含まれていた場合は指摘し、その部分について、このプルリクエストに含めずに別のプルリクエストを開いて変更するように指示してください。
path_filters:
- "!CHANGELOG.md"
- "!**/__snapshots__/**"
+14 -1
View File
@@ -241,7 +241,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 +251,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 -1
View File
@@ -474,7 +474,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 +484,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
+5 -1
View File
@@ -30,6 +30,7 @@
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -40,6 +41,7 @@
- Fix: チャートの描画終了後にリソースが解放されない問題を修正
- Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正
- Fix: QRコードリーダーがページを離れても停止しない問題を修正
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
### Server
- Feat: OpenTelemetryサポート
@@ -51,11 +53,12 @@
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Feat: ログ基盤の刷新(部分的に導入中)
- Feat: ログ基盤の刷新
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
- バックエンドのログを1行JSON形式で出力できるように
- OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、OpenTelemetryの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.22.2以降、24.17.0以降、26.4.0以降をサポートするように
@@ -67,6 +70,7 @@
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
## 2026.6.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.2",
"version": "2026.7.0-beta.5",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -1,28 +1,28 @@
## ⚙️ Backend Diagnostics Report
### Memory: After GC
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @ Base | @ 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 |
| **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 | @ Base | @ Head | Δ | MAD |
| 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{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 |
| $\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)
@@ -64,7 +64,7 @@ test('throws when filtering leaves fewer than two heap snapshots per side', asyn
})).toThrow('At least two samples per side are required');
});
test('reports the difference of medians and leaves a paired-looking PSS delta uncoloured', async () => {
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]);
@@ -78,16 +78,10 @@ test('reports the difference of medians and leaves a paired-looking PSS delta un
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
const row = findMetricRow(markdown, 'PSS');
expect(row).toContain('295.8 MB <br> ± 2.9 MB');
expect(row).toContain('296.3 MB <br> ± 3.4 MB');
expect(row).toContain('$\\text{+0.5 MB}$');
expect(row).toContain('4.5 MB');
expect(row.split('|')).toHaveLength(7);
expect(row).not.toMatch(/within noise|increase|decrease|inconclusive/);
expect(row).not.toContain('\\color{orange}');
expect(findMetricRow(markdown, 'USS')).not.toContain('\\color{orange}');
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 () => {
@@ -131,6 +125,6 @@ test('renders an unavailable percentage when the base median is zero', async ()
});
expect(findMetricRow(markdown, 'External')).toContain('<br>-');
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
expect(markdown).toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD | Result |');
});
@@ -131,7 +131,7 @@ export function chunkMarkdownTable(
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [
'| Chunk | Base | Head | Δ | Δ (%) |',
'| Chunk | Base | Head | Δ | Δ (%) |', // nbspにすること
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
@@ -150,7 +150,8 @@ export function chunkMarkdownTable(
}
}
if (hasGenerated) {
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`);
// 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)} |`);
@@ -194,7 +194,7 @@ export function renderVisualizerSummaryTable(base: ReturnType<typeof collectVisu
...metrics.map((key) => `<td>${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}</td>`),
'</tr>',
'<tr>',
'<th><b>Δ (%)</b></th>',
'<th><b>Δ&nbsp;(%)</b></th>',
...summary.map((key) => `<td>${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}</td>`),
'</tr>',
@@ -1,12 +1,12 @@
## 🖥 Frontend Diagnostics Report
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| **Encoded network** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+83 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Decoded body** | 3.5 MB <br> ± 34 KB | 3.7 MB <br> ± 37 KB | $\color{orange}{\text{+277 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 50 KB |
| **Same-origin encoded** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+82 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Script encoded** | 918 KB <br> ± 9 KB | 991 KB <br> ± 9.7 KB | $\color{orange}{\text{+73 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 13 KB |
| **Page-attributed memory** | 92 MB <br> ± 900 KB | 99 MB <br> ± 972 KB | $\color{orange}{\text{+7.3 MB}}$<br>$\color{orange}{\text{+8\\%}}$ | 1.3 MB |
| **Encoded network** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+83 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Decoded body** | 3.5 MB <br> ± 34 KB | 3.7 MB <br> ± 37 KB | $\color{orange}{\text{+277 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 50 KB |
| **Same-origin encoded** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+82 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Script encoded** | 918 KB <br> ± 9 KB | 991 KB <br> ± 9.7 KB | $\color{orange}{\text{+73 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 13 KB |
| **Page-attributed memory** | 92 MB <br> ± 900 KB | 99 MB <br> ± 972 KB | $\color{orange}{\text{+7.3 MB}}$<br>$\color{orange}{\text{+8\\%}}$ | 1.3 MB |
<small><i>Only metrics showing significant changes are displayed.</i></small>
@@ -37,27 +37,27 @@
<td align="right">10</td>
<td align="right">10</td>
<td align="right">0</td>
<td align="right">918 KB</td>
<td align="right">991 KB</td>
<td align="right">$\color{orange}{\text{+73 KB}}$</td>
<td align="right">918 KB</td>
<td align="right">991 KB</td>
<td align="right">$\color{orange}{\text{+73 KB}}$</td>
</tr>
<tr>
<td><b>Stylesheet</b></td>
<td align="right">2</td>
<td align="right">2</td>
<td align="right">0</td>
<td align="right">82 KB</td>
<td align="right">88 KB</td>
<td align="right">$\color{orange}{\text{+6.5 KB}}$</td>
<td align="right">82 KB</td>
<td align="right">88 KB</td>
<td align="right">$\color{orange}{\text{+6.5 KB}}$</td>
</tr>
<tr>
<td><b>Fetch</b></td>
<td align="right">6</td>
<td align="right">6</td>
<td align="right">0</td>
<td align="right">41 KB</td>
<td align="right">44 KB</td>
<td align="right">$\color{orange}{\text{+3.3 KB}}$</td>
<td align="right">41 KB</td>
<td align="right">44 KB</td>
<td align="right">$\color{orange}{\text{+3.3 KB}}$</td>
</tr>
</tbody>
</table>
@@ -67,17 +67,17 @@
<details>
<summary>V8 heap snapshot statistics</summary>
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\text{+8\\%}$ | 15 KB |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\text{+8\\%}$ | 15 KB |
| | | | | |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
</details>
@@ -87,25 +87,25 @@ Download representative heap snapshot: [base](https://example.invalid/base) / [h
<details>
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>
| Chunk | Base | Head | Δ | Δ (%) |
| Chunk | Base | Head | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ |
| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ |
| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
</details>
<details>
<summary>Startup chunk size (2 updated, 0 added, 0 removed)</summary>
| Chunk | Base | Head | Δ | Δ (%) |
| Chunk | Base | Head | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
@@ -138,9 +138,9 @@ _Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
<td>1</td>
<td>2</td>
<td>3</td>
<td>21 KB</td>
<td>6.3 KB</td>
<td>5.3 KB</td>
<td>21 KB</td>
<td>6.3 KB</td>
<td>5.3 KB</td>
</tr>
<tr>
<th><b>Head</b></th>
@@ -149,9 +149,9 @@ _Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
<td>1</td>
<td>3</td>
<td>3</td>
<td>31 KB</td>
<td>8.4 KB</td>
<td>7 KB</td>
<td>31 KB</td>
<td>8.4 KB</td>
<td>7 KB</td>
</tr>
<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
<tr>
@@ -161,12 +161,12 @@ _Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
<td>0</td>
<td>$\color{orange}{\text{+1}}$</td>
<td>0</td>
<td>$\color{orange}{\text{+9.8 KB}}$</td>
<td>$\color{orange}{\text{+2.1 KB}}$</td>
<td>$\color{orange}{\text{+1.8 KB}}$</td>
<td>$\color{orange}{\text{+9.8 KB}}$</td>
<td>$\color{orange}{\text{+2.1 KB}}$</td>
<td>$\color{orange}{\text{+1.8 KB}}$</td>
</tr>
<tr>
<th><b>Δ (%)</b></th>
<th><b>Δ&nbsp;(%)</b></th>
<td>0%</td>
<td>$\color{orange}{\text{+16.7\%}}$</td>
<td>0%</td>
@@ -232,8 +232,8 @@
<div><dt>Round</dt><dd>1</dd></div>
<div><dt>Base count</dt><dd>0</dd></div>
<div><dt>Head count</dt><dd>1</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>MIME</dt><dd>text/javascript</dd></div>
<div><dt>Protocol</dt><dd>h2</dd></div>
<div><dt>Remote</dt><dd>-</dd></div>
@@ -276,8 +276,8 @@
<div><dt>Round</dt><dd>2</dd></div>
<div><dt>Base count</dt><dd>0</dd></div>
<div><dt>Head count</dt><dd>1</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>MIME</dt><dd>text/javascript</dd></div>
<div><dt>Protocol</dt><dd>h2</dd></div>
<div><dt>Remote</dt><dd>-</dd></div>
@@ -320,8 +320,8 @@
<div><dt>Round</dt><dd>3</dd></div>
<div><dt>Base count</dt><dd>0</dd></div>
<div><dt>Head count</dt><dd>1</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>MIME</dt><dd>text/javascript</dd></div>
<div><dt>Protocol</dt><dd>h2</dd></div>
<div><dt>Remote</dt><dd>-</dd></div>
@@ -151,8 +151,8 @@ test('renders one frontend diagnostics markdown report from bundle and browser d
const markdown = await renderReport('https://example.invalid/html');
await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md');
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
expect(markdown).toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD | Result |');
expect(markdown).toContain('<summary>Requests by resource type</summary>');
expect(markdown).toContain('## 📦 Bundle Stats');
});
@@ -177,8 +177,8 @@ test('renders the difference of independent medians instead of the paired median
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('100 <br> ± 0');
expect(row).toContain('200 <br> ± 0');
expect(row).toContain('100 <br> ± 0');
expect(row).toContain('200 <br> ± 0');
expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$');
expect(row).toContain('| 0 |');
expect(row.split('|')).toHaveLength(7);
@@ -251,7 +251,7 @@ test('renders a directional encoded byte delta at the absolute threshold', async
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
});
test('colours absolute and relative deltas together when the row is significant', async () => {
@@ -268,9 +268,9 @@ test('colours absolute and relative deltas together when the row is significant'
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row.split('|')).toHaveLength(7);
});
@@ -33,7 +33,7 @@ export function formatNumber(value: number) {
}
export function formatBytes(value: number) {
if (value === 0) return '0 B';
if (value === 0) return '0 B'; // nbspにすること
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
@@ -43,7 +43,8 @@ export function formatBytes(value: number) {
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
// eslint-disable-next-line no-irregular-whitespace
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; // nbspにすること
}
/**
@@ -75,9 +76,9 @@ export function formatSecondsAsMs(value: number | null | undefined) {
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)))}}$`;
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta))).replaceAll(' ', '~')}}$`; // "1 B"とかで1とBの間で自動改行させないためnbsp(~)にする
const color = delta > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta))).replaceAll(' ', '~')}}}$`; // "1 B"とかで1とBの間で自動改行させないためnbsp(~)にする
}
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
@@ -20,7 +20,8 @@ function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategor
}
function swatch(category: HeapSnapshotCategory) {
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
// eslint-disable-next-line no-irregular-whitespace
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label.replaceAll(' ', ' ')}**`; // nbspにすること
}
/**
@@ -35,7 +35,8 @@ function formatMedian<T>(
) {
const formatted = row.formatValue(value);
if (row.showMedianMad === false) return formatted;
return `${formatted} <br> ± ${row.formatValue(spread)}`;
// eslint-disable-next-line no-irregular-whitespace
return `${formatted} <br> ± ${row.formatValue(spread)}`; // nbspにすること
}
function formatDelta<T>(
@@ -77,7 +78,7 @@ export function renderMetricComparisonTable<T>(
if (lines.length === 0) return '**(No significant changes)**';
return [
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
...lines,
...(omitted ? ['', '<small><i>Only metrics showing significant changes are displayed.</i></small>'] : []),
@@ -20,15 +20,15 @@ import {
describe('formatBytes', () => {
// 1024ではなく1000区切り。単位が2桁以上なら小数を落とす
test.each([
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
])('formats %i as %s', (input, expected) => {
expect(formatBytes(input)).toBe(expected);
});
@@ -45,7 +45,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_100_000, 1_200_000, 1_300_000]),
));
expect(row).toContain('$\\text{+100 KB}$');
expect(row).toContain('$\\text{+100 KB}$');
expect(row).not.toContain('within noise');
expect(row).not.toContain('\\color{orange}');
});
@@ -56,7 +56,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_200_000, 1_200_000, 1_200_000]),
));
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
expect(row).not.toContain('increase');
});
@@ -67,7 +67,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_050_000, 1_050_000, 1_050_000]),
));
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row.split('|')[4]).not.toContain('\\color{');
});
@@ -88,7 +88,7 @@ describe('renderHeapSnapshotTable', () => {
const categoryRow = lines.find(line => line.includes('**Code**'));
expect(lines.slice(0, 2)).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
]);
expect(table).not.toContain('Result');
@@ -31,9 +31,9 @@ describe('renderMetricComparisonTable', () => {
);
expect(table.split('\n')).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20 units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
]);
});
@@ -50,9 +50,9 @@ describe('renderMetricComparisonTable', () => {
);
expect(table.split('\n')).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20 units}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20~units}}$ | 0 units |',
'| | | | | |',
]);
});
@@ -62,7 +62,7 @@ describe('renderMetricComparisonTable', () => {
const head = samples(109, 109, 109);
const table = renderMetricComparisonTable(base, head, [defaultRow]);
expect(table).toContain('$\\text{+9 units}$<br>$\\text{+9\\\\%}$');
expect(table).toContain('$\\text{+9~units}$<br>$\\text{+9\\\\%}$');
expect(table).not.toContain('\\color{');
expect(renderMetricComparisonTable(base, head, [defaultRow], {
onlySignificantChanges: true,
@@ -85,7 +85,7 @@ describe('renderMetricComparisonTable', () => {
{ onlySignificantChanges: true },
);
expect(table).toContain('$\\color{orange}{\\text{+10 units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10~units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$');
});
@@ -106,11 +106,11 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(inside).toContain('$\\text{+9 units}$');
expect(inside).toContain('$\\text{+9~units}$');
expect(inside).not.toContain('\\color{');
expect(boundary).toContain('$\\text{+30 units}$');
expect(boundary).toContain('$\\text{+30~units}$');
expect(boundary).not.toContain('\\color{');
expect(outside).toContain('$\\color{orange}{\\text{+31 units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31~units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31\\\\%}}$');
});
@@ -121,7 +121,7 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(table).toContain('$\\color{green}{\\text{-20 units}}$');
expect(table).toContain('$\\color{green}{\\text{-20~units}}$');
expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$');
});
@@ -132,7 +132,7 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(table).toContain('$\\color{orange}{\\text{+20 units}}$<br>-');
expect(table).toContain('$\\color{orange}{\\text{+20~units}}$<br>-');
});
test('propagates the minimum sample contract', () => {
+3 -1
View File
@@ -10,7 +10,7 @@ import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis';
import type { LogFormat, LogLevelSetting } from './logging/types.js';
import type { AccessLogConfiguration, LogFormat, LogLevelSetting } from './logging/types.js';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
@@ -136,6 +136,7 @@ type Source = {
format?: LogFormat;
level?: LogLevelSetting;
domains?: Record<string, LogLevelSetting> | null;
access?: AccessLogConfiguration;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
@@ -201,6 +202,7 @@ export type Config = {
format?: LogFormat;
level?: LogLevelSetting;
domains?: Record<string, LogLevelSetting> | null;
access?: AccessLogConfiguration;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
+60 -11
View File
@@ -5,9 +5,12 @@
import { bindThis } from '@/decorators.js';
import { logManager } from './logging/logging-runtime.js';
import type { LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { Keyword } from 'color-convert';
// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。
type LegacyData = Record<string, any> | null;
/**
* ロガー名の階層と従来の公開APIを提供する薄い窓口です。
* 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。
@@ -52,6 +55,15 @@ export default class Logger {
});
}
/** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */
@bindThis
private logStructured(level: LogLevel, input: LogEntryInput): void {
this.write({
...input,
level,
});
}
/** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */
@bindThis
public write(input: LogWriteInput): void {
@@ -62,24 +74,35 @@ export default class Logger {
}
/** 処理を継続できない状況を記録します。 */
public error(input: LogEntryInput): void;
public error(error: Error, data?: LegacyData, important?: boolean): void;
public error(message: string, data?: LegacyData, important?: boolean): void;
public error(errorOrMessage: string | Error, data?: LegacyData, important?: boolean): void;
@bindThis
public error(x: string | Error, data?: Record<string, any> | null, important = false): void {
public error(x: LogEntryInput | string | Error, data?: LegacyData, important = false): void {
if (x instanceof Error) {
// エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
this.log('error', x.toString(), data, important, undefined, x);
} else if (typeof x === 'object') {
this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important);
} else if (typeof x === 'string') {
this.log('error', x, data, important);
} else {
this.log('error', `${x}`, data, important);
this.logStructured('error', x);
}
}
/** 処理は継続できるものの、改善が必要な状況を記録します。 */
public warn(input: LogEntryInput): void;
public warn(message: string): void;
public warn(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public warn(message: string, data?: Record<string, any> | null, important = false): void {
this.log('warn', message, data, important);
public warn(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('warn', inputOrMessage, data, important);
} else {
this.logStructured('warn', inputOrMessage);
}
}
/** 処理が成功したことを、従来のDONE表示で記録します。 */
@@ -89,14 +112,40 @@ export default class Logger {
}
/** 開発者向けの調査情報を記録します。 */
public debug(input: LogEntryInput): void;
public debug(message: string): void;
public debug(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public debug(message: string, data?: Record<string, any> | null, important = false): void {
this.log('debug', message, data, important);
public debug(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('debug', inputOrMessage, data, important);
} else {
this.logStructured('debug', inputOrMessage);
}
}
/** 通常の動作状況を記録します。 */
public info(input: LogEntryInput): void;
public info(message: string): void;
public info(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public info(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important);
public info(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('info', inputOrMessage, data, important);
} else {
this.logStructured('info', inputOrMessage);
}
}
/** 致命的な状況を構造化ログとして記録します。 */
public fatal(input: LogEntryInput): void;
public fatal(message: string): void;
@bindThis
public fatal(inputOrMessage: LogEntryInput | string): void {
if (typeof inputOrMessage === 'string') {
this.logStructured('fatal', { message: inputOrMessage });
} else {
this.logStructured('fatal', inputOrMessage);
}
}
}
@@ -4,7 +4,7 @@
*/
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogRecord } from './types.js';
/** 設定読込前の最小出力処理が外部から受け取る依存関係です。 */
export type BootstrapConsoleBackendDependencies = {
@@ -46,4 +46,9 @@ export class BootstrapConsoleBackend implements LogBackend {
this.dependencies.output(...args);
}
/** 設定前のAccess logは本文を含めず、最小限の情報だけを出力します。 */
public writeAccess(record: AccessLogRecord): void {
this.dependencies.output(`${record.timestamp} ACCESS ${record.method} ${record.route ?? '-'} ${record.statusCode}`);
}
}
@@ -4,7 +4,7 @@
*/
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogRecord } from './types.js';
/** JSON形式のログを1行で出力する処理が外部から受け取る依存関係です。 */
export type JsonConsoleBackendDependencies = {
@@ -28,6 +28,26 @@ type JsonLogRecord = {
readonly trace_flags?: number;
};
/** Access logのJSON出力項目です。通常ログと混同しない形を保ちます。 */
type JsonAccessLogRecord = {
readonly type: 'access';
readonly timestamp: string;
readonly method: string;
readonly route: string | null;
readonly statusCode: number;
readonly durationMs: number;
readonly responseSizeBytes: number | null;
readonly errorType?: string;
readonly requestBody?: AccessLogRecord['requestBody'];
readonly responseBody?: AccessLogRecord['responseBody'];
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
readonly trace_id?: string;
readonly span_id?: string;
readonly trace_flags?: number;
};
const defaultDependencies: JsonConsoleBackendDependencies = {
output: line => console.log(line),
};
@@ -55,6 +75,28 @@ function createJsonLogRecord(record: LogRecord): JsonLogRecord {
};
}
/** Access logから公開する項目だけを選び、1行JSONの形へ整えます。 */
function createJsonAccessLogRecord(record: AccessLogRecord): JsonAccessLogRecord {
return {
type: 'access',
timestamp: record.timestamp,
method: record.method,
route: record.route,
statusCode: record.statusCode,
durationMs: record.durationMs,
responseSizeBytes: record.responseSizeBytes,
...(record.errorType != null ? { errorType: record.errorType } : {}),
...(record.requestBody !== undefined ? { requestBody: record.requestBody } : {}),
...(record.responseBody !== undefined ? { responseBody: record.responseBody } : {}),
processId: record.processId,
isPrimary: record.isPrimary,
workerId: record.workerId,
...(record.traceId != null ? { trace_id: record.traceId } : {}),
...(record.spanId != null ? { span_id: record.spanId } : {}),
...(record.traceFlags != null ? { trace_flags: record.traceFlags } : {}),
};
}
/**
* LogRecordを1行のJSONへ変換し、ログ収集基盤が扱える標準出力へ渡します。
* LoggerやLogManagerから出力形式を切り離し、Pretty形式と同じ記録を共有します。
@@ -75,4 +117,10 @@ export class JsonConsoleBackend implements LogBackend {
// JSON.stringifyが改行などをエスケープするため、1ログ1行の契約を保てます。
this.dependencies.output(JSON.stringify(createJsonLogRecord(record)));
}
/** Access logを1行JSONとして出力します。本文は設定で有効化された場合のみ、秘匿処理済みで含まれます。 */
public writeAccess(record: AccessLogRecord): void {
// JSON.stringifyが改行をエスケープするため、1件を1物理行に保ちます。
this.dependencies.output(JSON.stringify(createJsonAccessLogRecord(record)));
}
}
+4 -1
View File
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogRecord } from './types.js';
/**
* 整形済みのログを実際の出力先へ渡すための共通窓口です。
@@ -13,6 +13,9 @@ export interface LogBackend {
/** ログを一件出力します。 */
write(record: LogRecord): void;
/** Access logを一件出力します。 */
writeAccess?(record: AccessLogRecord): void;
/** 保留中の出力がある場合に、すべて書き出します。 */
flush?(): void | Promise<void>;
+174 -7
View File
@@ -9,11 +9,23 @@ import { envOption } from '@/env.js';
import {
findLegacyLogError,
normalizeLogAttributes,
normalizeLogValue,
serializeLogError,
type LogNormalizationProfile,
} from './LogNormalizer.js';
import type { LogBackend } from './LogBackend.js';
import type { LogLevel, LogLevelSetting, LogRecord, LogRecordInput, LogTraceContextProvider } from './types.js';
import type {
AccessLogConfiguration,
AccessLogRecord,
AccessLogRecordInput,
AccessLogStatusClass,
LogLevel,
LogLevelSetting,
LogRecord,
LogRecordInput,
LogTraceContext,
LogTraceContextProvider,
} from './types.js';
/** ログを出力したプロセスを識別するための情報です。 */
export type LogProcessInfo = {
@@ -43,6 +55,17 @@ export type LogManagerOptions = {
export type LogManagerConfiguration = {
readonly level?: LogLevelSetting;
readonly domains?: Readonly<Record<string, LogLevelSetting>> | null;
readonly access?: AccessLogConfiguration;
};
/** 正規化済みのAccess log設定です。 */
export type ResolvedAccessLogConfiguration = {
readonly statusClasses: readonly AccessLogStatusClass[];
readonly bodies: {
readonly request: boolean;
readonly response: boolean;
readonly maxBytes: number;
};
};
const logLevelOrder: Readonly<Record<LogLevel, number>> = {
@@ -54,6 +77,9 @@ const logLevelOrder: Readonly<Record<LogLevel, number>> = {
};
const validLogLevels = new Set<LogLevelSetting>(['debug', 'info', 'warn', 'error', 'fatal', 'off']);
const validAccessStatusClasses = new Set<AccessLogStatusClass>(['2xx', '3xx', '4xx', '5xx']);
const defaultAccessBodyMaxBytes = 16 * 1024;
const maxAccessBodyBytes = 128 * 1024;
function validateLogLevel(value: unknown, path: string): LogLevelSetting | undefined {
if (typeof value === 'undefined') return undefined;
@@ -69,14 +95,96 @@ function validateDomainName(domain: string): void {
}
}
function resolveConfiguration(configuration: LogManagerConfiguration | undefined): {
/** Access logを明示的に有効化していない場合の設定を作成します。 */
function createDisabledAccessLogConfiguration(): ResolvedAccessLogConfiguration {
return {
statusClasses: [],
bodies: {
request: false,
response: false,
maxBytes: defaultAccessBodyMaxBytes,
},
};
}
/** Access log設定を検証し、本番環境では本文だけを無効化します。 */
function resolveAccessConfiguration(configuration: unknown, nodeEnv: string | undefined): {
readonly access: ResolvedAccessLogConfiguration;
readonly warnings: readonly string[];
} {
if (configuration == null) return { access: createDisabledAccessLogConfiguration(), warnings: [] };
if (typeof configuration !== 'object' || Array.isArray(configuration)) {
throw new Error('logging.access must be an object');
}
const raw = configuration as {
statusClasses?: unknown;
bodies?: unknown;
};
let statusClasses: AccessLogStatusClass[] = [];
if (typeof raw.statusClasses !== 'undefined') {
if (!Array.isArray(raw.statusClasses)) {
throw new Error('logging.access.statusClasses must be an array');
}
statusClasses = [...new Set(raw.statusClasses.map((statusClass, index) => {
if (typeof statusClass !== 'string' || !validAccessStatusClasses.has(statusClass as AccessLogStatusClass)) {
throw new Error(`logging.access.statusClasses[${index}] must be one of 2xx, 3xx, 4xx, or 5xx`);
}
return statusClass as AccessLogStatusClass;
}))];
}
let request = false;
let response = false;
let maxBytes = defaultAccessBodyMaxBytes;
if (typeof raw.bodies !== 'undefined') {
if (typeof raw.bodies !== 'object' || raw.bodies === null || Array.isArray(raw.bodies)) {
throw new Error('logging.access.bodies must be an object');
}
const bodies = raw.bodies as { request?: unknown; response?: unknown; maxBytes?: unknown };
if (typeof bodies.request !== 'undefined' && typeof bodies.request !== 'boolean') {
throw new Error('logging.access.bodies.request must be a boolean');
}
if (typeof bodies.response !== 'undefined' && typeof bodies.response !== 'boolean') {
throw new Error('logging.access.bodies.response must be a boolean');
}
const configuredMaxBytes = bodies.maxBytes;
if (typeof configuredMaxBytes !== 'undefined' && (typeof configuredMaxBytes !== 'number' || !Number.isSafeInteger(configuredMaxBytes) || configuredMaxBytes <= 0 || configuredMaxBytes > maxAccessBodyBytes)) {
throw new Error(`logging.access.bodies.maxBytes must be a positive integer no greater than ${maxAccessBodyBytes}`);
}
request = bodies.request ?? false;
response = bodies.response ?? false;
maxBytes = typeof configuredMaxBytes === 'number' ? configuredMaxBytes : defaultAccessBodyMaxBytes;
}
if (nodeEnv === 'production' && (request || response)) {
return {
access: {
statusClasses,
bodies: { request: false, response: false, maxBytes },
},
warnings: ['logging.access.bodies is disabled in production mode'],
};
}
return {
access: { statusClasses, bodies: { request, response, maxBytes } },
warnings: [],
};
}
/** 通常ログとAccess logの設定をまとめて検証し、起動時警告も返します。 */
function resolveConfiguration(configuration: LogManagerConfiguration | undefined, nodeEnv: string | undefined): {
readonly level: LogLevelSetting | undefined;
readonly domains: readonly (readonly [string, LogLevelSetting])[];
readonly access: ResolvedAccessLogConfiguration;
readonly warnings: readonly string[];
} {
if (configuration == null) return { level: undefined, domains: [] };
if (configuration == null) return { level: undefined, domains: [], access: createDisabledAccessLogConfiguration(), warnings: [] };
const level = validateLogLevel(configuration.level, 'logging.level');
if (configuration.domains == null) return { level, domains: [] };
const access = resolveAccessConfiguration(configuration.access, nodeEnv);
if (configuration.domains == null) return { level, domains: [], ...access };
if (typeof configuration.domains !== 'object' || configuration.domains === null || Array.isArray(configuration.domains)) {
throw new Error('logging.domains must be an object');
}
@@ -90,7 +198,7 @@ function resolveConfiguration(configuration: LogManagerConfiguration | undefined
return [domain, level] as const;
}).sort((left, right) => right[0].length - left[0].length);
return { level, domains };
return { level, domains, ...access };
}
const defaultDependencies: LogManagerDependencies = {
@@ -116,6 +224,7 @@ export class LogManager {
private traceContextProvider: LogTraceContextProvider | undefined;
private configuredLevel: LogLevelSetting | undefined;
private configuredDomains: readonly (readonly [string, LogLevelSetting])[];
private accessConfiguration: ResolvedAccessLogConfiguration;
private shutdownPromise: Promise<void> | undefined;
/**
@@ -136,6 +245,7 @@ export class LogManager {
this.traceContextProvider = undefined;
this.configuredLevel = undefined;
this.configuredDomains = [];
this.accessConfiguration = createDisabledAccessLogConfiguration();
}
/**
@@ -147,10 +257,17 @@ export class LogManager {
}
/** 起動時の既定levelとdomain別levelを適用します。 */
public configure(configuration?: LogManagerConfiguration): void {
const resolved = resolveConfiguration(configuration);
public configure(configuration?: LogManagerConfiguration): readonly string[] {
const resolved = resolveConfiguration(configuration, this.dependencies.getNodeEnv());
this.configuredLevel = resolved.level;
this.configuredDomains = resolved.domains;
this.accessConfiguration = resolved.access;
return resolved.warnings;
}
/** Fastifyフックが参照する正規化済みのAccess log設定を返します。 */
public getAccessLogConfiguration(): ResolvedAccessLogConfiguration {
return this.accessConfiguration;
}
/** 正規化方式を切り替え、既に作成済みのLoggerにも反映します。 */
@@ -163,6 +280,11 @@ export class LogManager {
this.traceContextProvider = provider;
}
/** 現在の処理に紐付くTrace Contextを取得します。 */
public getActiveTraceContext(): LogTraceContext | undefined {
return this.traceContextProvider?.();
}
/** backendに残っているログをflushしてから終了処理を行います。 */
public shutdown(): Promise<void> {
if (this.shutdownPromise != null) return this.shutdownPromise;
@@ -244,4 +366,49 @@ export class LogManager {
this.backend.write(record);
}
/** status classの設定を確認し、Access logの出力対象か判断します。 */
public shouldWriteAccess(statusCode: number): boolean {
if (this.dependencies.isQuiet()) return false;
const statusClass = `${Math.floor(statusCode / 100)}xx` as AccessLogStatusClass;
return validAccessStatusClasses.has(statusClass) && this.accessConfiguration.statusClasses.includes(statusClass);
}
/** Access logのフック自体を登録してよい状態か、quietを含めて判定します。 */
public isAccessLogEnabled(): boolean {
return !this.dependencies.isQuiet() && this.accessConfiguration.statusClasses.length > 0;
}
/** HTTP応答へ共通情報と本文の安全な正規化を加えてAccess logを渡します。 */
public writeAccess(input: AccessLogRecordInput): void {
if (!this.shouldWriteAccess(input.statusCode)) return;
const processInfo = this.dependencies.getProcessInfo();
const { requestBody, responseBody, traceContext, ...inputWithoutOptionalValues } = input;
const bodyOptions = {
profile: this.normalizationProfile,
limits: { maxBytes: this.accessConfiguration.bodies.maxBytes },
} as const;
const normalizedRequestBody = this.accessConfiguration.bodies.request && typeof requestBody !== 'undefined'
? normalizeLogValue(requestBody, bodyOptions)
: undefined;
const normalizedResponseBody = this.accessConfiguration.bodies.response && typeof responseBody !== 'undefined'
? normalizeLogValue(responseBody, bodyOptions)
: undefined;
// HTTPフックが開始時に保持したContextだけを使い、応答時に別のSpanを紐付けません。
const resolvedTraceContext = traceContext;
const record: AccessLogRecord = {
type: 'access',
...inputWithoutOptionalValues,
timestamp: this.dependencies.now().toISOString(),
processId: processInfo.processId,
isPrimary: processInfo.isPrimary,
workerId: processInfo.workerId,
...(typeof normalizedRequestBody !== 'undefined' ? { requestBody: normalizedRequestBody } : {}),
...(typeof normalizedResponseBody !== 'undefined' ? { responseBody: normalizedResponseBody } : {}),
...(resolvedTraceContext ?? {}),
};
this.backend.writeAccess?.(record);
}
}
@@ -60,6 +60,7 @@ const sensitiveKeyParts = [
'apikey',
'privatekey',
'credential',
'captcha',
'hcaptcharesponse',
'grecaptcharesponse',
'turnstileresponse',
@@ -290,6 +291,14 @@ export function normalizeLogAttributes(value: unknown, options: LogNormalization
return trimToByteLimit(root as LogAttributes, limits.maxBytes) as LogAttributes;
}
/** 本文など単独の値を、既存ログと同じ秘匿・切り詰め規則で正規化します。 */
export function normalizeLogValue(value: unknown, options: LogNormalizationOptions = {}): LogAttributeValue {
const limits = resolveLogNormalizationLimits(options);
const redactor = options.redactor ?? defaultLogRedactor;
const normalized = normalizeValue(value, [], 0, new WeakSet<object>(), limits, redactor);
return trimToByteLimit(normalized, limits.maxBytes);
}
/** 旧APIのdata領域から、エラー本体を見つけて構造化した形へ渡します。 */
export function findLegacyLogError(value: unknown): unknown {
if (isErrorValue(value)) return value;
@@ -8,7 +8,7 @@ import { default as convertColor } from 'color-convert';
import { format as dateFormat } from 'date-fns';
import { envOption } from '@/env.js';
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogAttributeValue, LogRecord } from './types.js';
/** 見やすい形式の出力処理が外部から受け取る依存関係です。 */
export type PrettyConsoleBackendDependencies = {
@@ -21,6 +21,15 @@ const defaultDependencies: PrettyConsoleBackendDependencies = {
withLogTime: () => envOption.withLogTime,
};
/** Pretty形式でだけnull prototypeを通常のオブジェクトへ変換し、正規化済みの値自体は変更しません。 */
function toPrettyLogValue(value: LogAttributeValue): LogAttributeValue {
if (Array.isArray(value)) return value.map(item => toPrettyLogValue(item));
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, toPrettyLogValue(child)]));
}
return value;
}
/**
* 人が読みやすい従来形式へ整形し、コンソールへ出力します。
* 色、ラベル、時刻などの見た目だけを担当し、出力可否はLogManagerへ任せます。
@@ -87,4 +96,30 @@ export class PrettyConsoleBackend implements LogBackend {
}
this.dependencies.output(...args);
}
/** Access logを人が読みやすい1行へ整形し、本文は追加情報として表示します。 */
public writeAccess(record: AccessLogRecord): void {
const statusLevel = record.statusCode >= 500 ? 'error' : record.statusCode >= 400 ? 'warn' : 'info';
const label =
statusLevel === 'error' ? chalk.red('ERR ') :
statusLevel === 'warn' ? chalk.yellow('WARN') :
chalk.blue('INFO');
const worker = record.isPrimary ? '*' : record.workerId;
const route = record.route ?? '-';
const size = record.responseSizeBytes == null ? '-' : `${record.responseSizeBytes}B`;
const message = `${record.method} ${route} ${record.statusCode} ${record.durationMs.toFixed(2)}ms ${size}`;
let log = `${label} ${worker}\t[access]\t${message}`;
if (this.dependencies.withLogTime()) {
log = chalk.gray(dateFormat(new Date(record.timestamp), 'HH:mm:ss')) + ' ' + log;
}
const details = {
...(record.errorType != null ? { errorType: record.errorType } : {}),
...(record.requestBody !== undefined ? { requestBody: toPrettyLogValue(record.requestBody) } : {}),
...(record.responseBody !== undefined ? { responseBody: toPrettyLogValue(record.responseBody) } : {}),
};
const args: unknown[] = [log];
if (Object.keys(details).length > 0) args.push(details);
this.dependencies.output(...args);
}
}
@@ -28,8 +28,16 @@ function createLoggingBackend(format: unknown): LogBackend {
export function configureLogging(configuration?: LogManagerConfiguration & { readonly format?: LogFormat }): void {
// 出力処理を先に検証し、設定値が不正な場合は現在の出力処理を壊さないようにします。
const backend = createLoggingBackend(configuration?.format);
logManager.configure(configuration);
const warnings = logManager.configure(configuration);
logManager.setBackend(backend);
// 設定を無視した理由を、選択済みの形式で起動時に一度だけ知らせます。
for (const message of warnings) {
logManager.write({
level: 'warn',
message,
context: [{ name: 'logging' }],
});
}
}
/** Telemetry初期化後に、ログへTrace Contextを付加する取得処理を登録します。 */
+50 -3
View File
@@ -14,6 +14,22 @@ export type LogLevelSetting = LogLevel | 'off';
/** コンソールへ出すログ形式です。未指定時は見やすい形式を使用します。 */
export type LogFormat = 'pretty' | 'json';
/** Access logで対象にできるHTTP status classです。 */
export type AccessLogStatusClass = '2xx' | '3xx' | '4xx' | '5xx';
/** Access logの本文採取設定です。 */
export type AccessLogBodyConfiguration = {
readonly request?: boolean;
readonly response?: boolean;
readonly maxBytes?: number;
};
/** Access logの出力設定です。 */
export type AccessLogConfiguration = {
readonly statusClasses?: readonly AccessLogStatusClass[];
readonly bodies?: AccessLogBodyConfiguration;
};
/** 正規化後にログ属性として扱えるJSONの値です。 */
export type LogAttributeValue =
| string
@@ -36,15 +52,19 @@ export type LogTraceContext = {
/** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */
export type LogTraceContextProvider = () => LogTraceContext | undefined;
/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */
export type LogWriteInput = {
readonly level: LogLevel;
/** level別のLoggerメソッドが受け取る構造化ログの共通入力です。 */
export type LogEntryInput = {
readonly message: string;
readonly eventName?: string;
readonly attributes?: Readonly<Record<string, unknown>>;
readonly error?: unknown;
};
/** 動的なlevelを含めてLogManagerへ渡す構造化ログの入力です。 */
export type LogWriteInput = LogEntryInput & {
readonly level: LogLevel;
};
/**
* ロガー名を構成する一要素です。
* 色は見やすい形式での表示だけに使い、ログの意味には影響させません。
@@ -97,3 +117,30 @@ export type LogRecord = Omit<LogRecordInput, 'attributes' | 'error'> & {
readonly attributes?: LogAttributes;
readonly error?: SerializedError;
};
/** Access logとして記録するHTTP応答の入力です。 */
export type AccessLogRecordInput = {
readonly method: string;
readonly route: string | null;
readonly statusCode: number;
readonly durationMs: number;
readonly responseSizeBytes: number | null;
readonly errorType?: string;
readonly requestBody?: unknown;
readonly responseBody?: unknown;
readonly traceContext?: LogTraceContext;
};
/** 出力先へ渡すAccess logです。本文は正規化後の値だけを含みます。 */
export type AccessLogRecord = Omit<AccessLogRecordInput, 'requestBody' | 'responseBody' | 'traceContext'> & {
readonly type: 'access';
readonly timestamp: string;
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
readonly requestBody?: LogAttributeValue;
readonly responseBody?: LogAttributeValue;
readonly traceId?: string;
readonly spanId?: string;
readonly traceFlags?: number;
};
@@ -33,6 +33,7 @@ import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
import { registerHttpAccessLog } from './http-access-log.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -82,6 +83,7 @@ export class ServerService implements OnApplicationShutdown {
});
this.#fastify = fastify;
await registerHttpServerInstrumentation(fastify, this.config);
registerHttpAccessLog(fastify);
// HSTS
// 6months (15552000sec)
@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiMeta, UsersRepository } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@@ -77,6 +78,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private signupService: SignupService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
@@ -93,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
throw new ApiError(meta.errors.wrongInitialPassword);
}
} else if ((this.serverSettings.rootUserId != null && (this.serverSettings.rootUserId !== me?.id)) || token !== null) {
} else if (token !== null || !(await this.roleService.isAdministrator(me))) {
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
throw new ApiError(meta.errors.accessDenied);
}
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Buffer } from 'node:buffer';
import type { FastifyInstance } from 'fastify';
import { logManager } from '@/logging/logging-runtime.js';
import type { LogManager } from '@/logging/LogManager.js';
import type { LogTraceContext } from '@/logging/types.js';
type AccessRequestState = {
traceContext?: LogTraceContext;
errorType?: string;
requestBody?: unknown;
responseBody?: unknown;
};
type CapturedBody = {
value: unknown;
};
/** Content-Typeから本文の種類だけを取り出します。 */
function getMediaType(value: string | string[] | number | undefined): string | undefined {
const first = Array.isArray(value) ? value[0] : value;
return typeof first === 'string' ? first.split(';', 1)[0].trim().toLowerCase() : undefined;
}
/** 秘匿処理を適用できるJSON・form・text本文か判定します。 */
function isSupportedMediaType(value: string | undefined): boolean {
return value === 'application/json'
|| (value?.startsWith('application/') === true && value.endsWith('+json'))
|| value === 'application/x-www-form-urlencoded'
|| (value?.startsWith('text/') === true && value !== 'text/event-stream');
}
/** application/jsonとapplication/*+jsonを構造化対象として判定します。 */
function isJsonMediaType(value: string | undefined): boolean {
return value === 'application/json'
|| (value?.startsWith('application/') === true && value.endsWith('+json'));
}
/** Streamやバイナリを本文ログの対象から外します。 */
function isStream(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
try {
const candidate = value as {
pipe?: unknown;
getReader?: unknown;
[Symbol.asyncIterator]?: unknown;
};
return typeof candidate.pipe === 'function'
|| typeof candidate.getReader === 'function'
|| typeof candidate[Symbol.asyncIterator] === 'function';
} catch {
// 本文のgetterが壊れていても、ログ処理が応答を失敗させないよう除外します。
return true;
}
}
/** JSON文字列を構造化し、解析できない場合は元の文字列を保持します。 */
function parseJsonBody(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** form本文を項目ごとの値へ分解し、キー単位の秘匿処理を可能にします。 */
function parseFormBody(value: string): Record<string, string | string[]> {
const result: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>;
for (const [key, item] of new URLSearchParams(value)) {
const previous = result[key];
result[key] = typeof previous === 'undefined'
? item
: Array.isArray(previous) ? [...previous, item] : [previous, item];
}
return result;
}
/** 送受信本文から、ログへ渡せる値だけを取り出します。 */
function captureBody(value: unknown, contentType: string | string[] | number | undefined): CapturedBody | undefined {
const mediaType = getMediaType(contentType);
if (!isSupportedMediaType(mediaType) || isStream(value)) return undefined;
if (isJsonMediaType(mediaType)) {
if (typeof value === 'string') return { value: parseJsonBody(value) };
if (Buffer.isBuffer(value)) return { value: parseJsonBody(value.toString('utf8')) };
return { value };
}
if (mediaType === 'application/x-www-form-urlencoded') {
if (typeof value === 'string') return { value: parseFormBody(value) };
if (Buffer.isBuffer(value)) return { value: parseFormBody(value.toString('utf8')) };
}
if (typeof value === 'string') return { value };
if (Buffer.isBuffer(value)) return { value: value.toString('utf8') };
// form parserなどが返す構造化済みの本文も、通常の正規化処理へ渡します。
if (typeof value === 'object' && value !== null) return { value };
return undefined;
}
/** Errorから本文を含めない型名だけを取り出します。 */
function getErrorType(error: unknown): string {
if (typeof error === 'object' && error !== null && typeof (error as { name?: unknown }).name === 'string') {
const name = (error as { name: string }).name;
if (name.length > 0) return name;
}
return 'Error';
}
/** content-lengthを安全なバイト数へ変換し、未知の応答ではnullを返します。 */
function getResponseSize(value: string | string[] | number | undefined): number | null {
const first = Array.isArray(value) ? value[0] : value;
const size = typeof first === 'number' ? first : typeof first === 'string' && /^\d+$/.test(first) ? Number(first) : NaN;
return Number.isSafeInteger(size) && size >= 0 ? size : null;
}
/** Fastify全体へAccess log用のリクエスト・エラー・応答フックを登録します。 */
export function registerHttpAccessLog(fastify: FastifyInstance, manager: LogManager = logManager): void {
if (!manager.isAccessLogEnabled()) return;
const states = new WeakMap<object, AccessRequestState>();
// HTTP計装の後に登録し、リクエスト開始時のactiveなTrace Contextを保存します。
fastify.addHook('onRequest', (request, _reply, done) => {
states.set(request, {
traceContext: manager.getActiveTraceContext(),
});
done();
});
// Fastifyが応答へ変換したErrorから、型名だけをリクエストへ一時保存します。
fastify.addHook('onError', (request, _reply, error, done) => {
const state = states.get(request) ?? {};
state.errorType = getErrorType(error);
states.set(request, state);
done();
});
// 送信前のpayloadを読み取りますが、元の値は変更せず、そのまま次のhookへ渡します。
fastify.addHook('onSend', (request, reply, payload, done) => {
if (!manager.shouldWriteAccess(reply.statusCode)) {
done(null, payload);
return;
}
const bodyConfiguration = manager.getAccessLogConfiguration().bodies;
const state = states.get(request) ?? {};
if (bodyConfiguration.request && !('requestBody' in state)) {
// ActivityPubもFastifyが解析したrequest.bodyだけを対象にし、rawBodyやheaderは参照しません。
const captured = captureBody(request.body, request.headers['content-type']);
if (captured !== undefined) state.requestBody = captured.value;
}
if (bodyConfiguration.response && !('responseBody' in state)) {
const captured = captureBody(payload, reply.getHeader('content-type'));
if (captured !== undefined) state.responseBody = captured.value;
}
states.set(request, state);
done(null, payload);
});
// 応答完了時にFastifyの値を集め、LogManagerへAccess logを渡します。
fastify.addHook('onResponse', (request, reply, done) => {
const state = states.get(request);
const input = {
method: request.method,
route: request.routeOptions.url ?? null,
statusCode: reply.statusCode,
durationMs: reply.elapsedTime,
responseSizeBytes: getResponseSize(reply.getHeader('content-length')),
...(state?.errorType !== undefined ? { errorType: state.errorType } : {}),
...(state != null && 'requestBody' in state ? { requestBody: state.requestBody } : {}),
...(state != null && 'responseBody' in state ? { responseBody: state.responseBody } : {}),
...(state?.traceContext !== undefined ? { traceContext: state.traceContext } : {}),
};
manager.writeAccess(input);
states.delete(request);
done();
});
}
@@ -4,7 +4,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import type { LogRecord } from '@/logging/types.js';
import type { AccessLogRecord, LogRecord } from '@/logging/types.js';
import { JsonConsoleBackend } from '@/logging/JsonConsoleBackend.js';
/** JSON形式のテストで使う共通のログを作成します。 */
@@ -23,6 +23,48 @@ function createRecord(overrides: Partial<LogRecord> = {}): LogRecord {
}
describe('JsonConsoleBackend', () => {
test('writes Access log as a separate one-line schema', () => {
const output = vi.fn<(line: string) => void>();
const backend = new JsonConsoleBackend({ output });
const record: AccessLogRecord = {
type: 'access',
timestamp: '2025-01-02T03:04:05.678Z',
method: 'GET',
route: '/items/:id',
statusCode: 500,
durationMs: 12.5,
responseSizeBytes: null,
errorType: 'TypeError',
requestBody: { token: '[REDACTED]' },
processId: 1234,
isPrimary: true,
workerId: null,
traceId: 'trace',
spanId: 'span',
traceFlags: 1,
};
backend.writeAccess(record);
expect(JSON.parse(output.mock.calls[0][0])).toEqual({
type: 'access',
timestamp: '2025-01-02T03:04:05.678Z',
method: 'GET',
route: '/items/:id',
statusCode: 500,
durationMs: 12.5,
responseSizeBytes: null,
errorType: 'TypeError',
requestBody: { token: '[REDACTED]' },
processId: 1234,
isPrimary: true,
workerId: null,
trace_id: 'trace',
span_id: 'span',
trace_flags: 1,
});
});
test('writes a stable one-line JSON record', () => {
const output = vi.fn<(line: string) => void>();
const backend = new JsonConsoleBackend({ output });
@@ -5,7 +5,7 @@
import { describe, expect, test, vi } from 'vitest';
import type { LogBackend } from '@/logging/LogBackend.js';
import type { LogRecordInput, LogTraceContext } from '@/logging/types.js';
import type { AccessLogRecord, AccessLogRecordInput, LogRecordInput, LogTraceContext } from '@/logging/types.js';
import { LogManager } from '@/logging/LogManager.js';
/** テストで使う最小構成のログ入力を作成します。 */
@@ -31,10 +31,15 @@ function createManager(options: {
configuration?: {
level?: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off';
domains?: Record<string, 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off'>;
access?: {
statusClasses?: ('2xx' | '3xx' | '4xx' | '5xx')[];
bodies?: { request?: boolean; response?: boolean; maxBytes?: number };
};
};
} = {}) {
const write = vi.fn<LogBackend['write']>();
const manager = new LogManager({ write }, {
const writeAccess = vi.fn<(record: AccessLogRecord) => void>();
const manager = new LogManager({ write, writeAccess }, {
now: () => new Date('2025-01-02T03:04:05.678Z'),
getProcessInfo: () => ({
processId: 1234,
@@ -49,7 +54,7 @@ function createManager(options: {
});
if (options.configuration) manager.configure(options.configuration);
return { manager, write };
return { manager, write, writeAccess };
}
describe('LogManager', () => {
@@ -221,6 +226,89 @@ describe('LogManager', () => {
expect(() => manager.configure({ level: 'notice' as never })).toThrow('logging.level');
expect(() => manager.configure({ domains: { queue: 'notice' as never } })).toThrow('logging.domains.queue');
expect(() => manager.configure({ domains: { 'queue.': 'info' } })).toThrow('invalid domain name');
expect(() => manager.configure({ access: { statusClasses: ['1xx' as never] } })).toThrow('statusClasses');
expect(() => manager.configure({ access: { statusClasses: '4xx' as never } })).toThrow('statusClasses');
expect(() => manager.configure({ access: { bodies: null as never } })).toThrow('bodies');
expect(() => manager.configure({ access: { bodies: { maxBytes: 0 } } })).toThrow('maxBytes');
expect(() => manager.configure({ access: { bodies: { maxBytes: 1.5 } } })).toThrow('maxBytes');
expect(() => manager.configure({ access: { bodies: { maxBytes: 128 * 1024 + 1 } } })).toThrow('maxBytes');
});
test('filters Access log by status class and keeps it independent from application level', () => {
const { manager, write, writeAccess } = createManager({ configuration: { level: 'off', access: { statusClasses: ['4xx', '5xx'] } } });
const input: AccessLogRecordInput = {
method: 'GET',
route: '/items/:id',
statusCode: 200,
durationMs: 1,
responseSizeBytes: 10,
};
manager.writeAccess(input);
manager.writeAccess({ ...input, statusCode: 404 });
manager.writeAccess({ ...input, statusCode: 500 });
expect(write).not.toHaveBeenCalled();
expect(writeAccess).toHaveBeenCalledTimes(2);
expect(writeAccess.mock.calls[0][0]).toMatchObject({ type: 'access', statusCode: 404, processId: 1234, workerId: null });
});
test('normalizes Access log bodies and keeps captured Trace Context', () => {
const { manager, writeAccess } = createManager({
configuration: {
access: {
statusClasses: ['2xx'],
bodies: { request: true, response: true, maxBytes: 1024 },
},
},
});
const input: AccessLogRecordInput = {
method: 'POST',
route: '/body',
statusCode: 200,
durationMs: 2,
responseSizeBytes: 20,
requestBody: { i: 'secret', nested: { password: 'secret' } },
responseBody: { token: 'secret', value: 'visible' },
traceContext: { traceId: 'trace', spanId: 'span', traceFlags: 1 },
};
manager.writeAccess(input);
expect(writeAccess).toHaveBeenCalledWith(expect.objectContaining({
requestBody: { i: '[REDACTED]', nested: { password: '[REDACTED]' } },
responseBody: { token: '[REDACTED]', value: 'visible' },
traceId: 'trace',
spanId: 'span',
}));
});
test('does not write Access log by default and preserves the default body limit', () => {
const { manager, writeAccess } = createManager();
manager.writeAccess({
method: 'GET',
route: '/disabled',
statusCode: 200,
durationMs: 1,
responseSizeBytes: null,
});
expect(manager.getAccessLogConfiguration().bodies.maxBytes).toBe(16 * 1024);
expect(writeAccess).not.toHaveBeenCalled();
});
test('accepts the maximum configured body limit', () => {
const { manager } = createManager({
configuration: {
access: {
statusClasses: ['2xx'],
bodies: { maxBytes: 128 * 1024 },
},
},
});
expect(manager.getAccessLogConfiguration().bodies.maxBytes).toBe(128 * 1024);
});
test('uses a replaced backend for subsequent records', () => {
@@ -7,6 +7,7 @@ import { describe, expect, test } from 'vitest';
import {
findLegacyLogError,
normalizeLogAttributes,
normalizeLogValue,
serializeLogError,
} from '@/logging/LogNormalizer.js';
@@ -25,6 +26,13 @@ describe('LogNormalizer', () => {
});
});
test('normalizes a standalone body value with the same redaction and size rules', () => {
const normalized = normalizeLogValue({ token: 'secret', text: 'x'.repeat(100) }, { limits: { maxBytes: 256 } });
expect(normalized).toMatchObject({ token: '[REDACTED]' });
expect(Buffer.byteLength(JSON.stringify(normalized), 'utf8')).toBeLessThanOrEqual(256);
});
test('marks unsupported objects and invalid dates without throwing', () => {
expect(normalizeLogAttributes({
map: new Map([['key', 'value']]),
@@ -49,6 +57,7 @@ describe('LogNormalizer', () => {
i: 'top-level-token',
request: {
Authorization: 'bearer-token',
captcha: 'captcha-value',
password: 'password',
nested: [{ api_key: 'api-key' }],
},
@@ -57,6 +66,7 @@ describe('LogNormalizer', () => {
i: '[REDACTED]',
request: {
Authorization: '[REDACTED]',
captcha: '[REDACTED]',
password: '[REDACTED]',
nested: [{ api_key: '[REDACTED]' }],
},
@@ -76,6 +76,91 @@ describe('Logger', () => {
}));
});
test('supports structured input through every level-specific method', () => {
const logger = new Logger('root').createSubLogger('child');
const error = new Error('broken');
const input = {
eventName: 'example.failed',
message: 'failed',
attributes: { id: 'id' },
error,
};
logger.debug(input);
logger.info(input);
logger.warn(input);
logger.error(input);
logger.fatal(input);
expect(mocks.write.mock.calls.map(([entry]) => entry.level)).toEqual([
'debug',
'info',
'warn',
'error',
'fatal',
]);
for (const [entry] of mocks.write.mock.calls) {
expect(entry).toMatchObject({
...input,
context: [
{ name: 'root', color: undefined },
{ name: 'child', color: undefined },
],
});
expect(entry).not.toHaveProperty('compatibility');
}
});
test('level-specific methods own the level even for runtime-invalid input', () => {
const logger = new Logger('root');
// @ts-expect-error level is selected by the method rather than the input object
logger.warn({ level: 'error', message: 'warning' });
expect(mocks.write.mock.calls[0][0]).toMatchObject({
level: 'warn',
message: 'warning',
});
});
test('preserves the legacy string signatures for non-error levels', () => {
const logger = new Logger('root');
logger.debug('debug', { source: 'debug' }, true);
logger.info('info', null, true);
logger.warn('warn', { source: 'warn' });
expect(mocks.write.mock.calls.map(([entry]) => entry.compatibility)).toEqual([
{ legacyLevel: undefined, important: true, data: { source: 'debug' } },
{ legacyLevel: undefined, important: true, data: null },
{ legacyLevel: undefined, important: false, data: { source: 'warn' } },
]);
});
test('records a fatal string through the structured API', () => {
new Logger('root').fatal('fatal message');
expect(mocks.write).toHaveBeenCalledWith({
level: 'fatal',
message: 'fatal message',
context: [{ name: 'root', color: undefined }],
});
});
test('preserves the legacy error string signature', () => {
new Logger('root').error('failed', { requestId: 'request' }, true);
expect(mocks.write).toHaveBeenCalledWith({
level: 'error',
message: 'failed',
context: [{ name: 'root', color: undefined }],
compatibility: {
legacyLevel: undefined,
important: true,
data: { requestId: 'request' },
},
});
});
test('uses Error.toString and adds the Error to existing data', () => {
const logger = new Logger('root');
const error = new TypeError('broken');
@@ -4,7 +4,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import type { LogRecord } from '@/logging/types.js';
import type { AccessLogRecord, LogRecord } from '@/logging/types.js';
type Formatter = ((value: unknown) => string) & { white?: Formatter };
@@ -50,6 +50,23 @@ function createRecord(overrides: Partial<LogRecord> = {}): LogRecord {
};
}
/** Access logの表示確認用に、正規化後と同じnull prototypeの本文を作成します。 */
function createAccessRecord(requestBody: AccessLogRecord['requestBody']): AccessLogRecord {
return {
type: 'access',
timestamp: '2025-01-02T03:04:05.678Z',
method: 'POST',
route: '/api/test',
statusCode: 200,
durationMs: 1,
responseSizeBytes: 10,
requestBody,
processId: 1234,
isPrimary: true,
workerId: null,
};
}
describe('PrettyConsoleBackend', () => {
test.each([
{ level: 'error', label: '<red>ERR </red>', message: '<red>message</red>' },
@@ -164,4 +181,23 @@ describe('PrettyConsoleBackend', () => {
},
);
});
test('shows normalized body maps as regular objects in pretty output', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
const nested = Object.create(null) as Record<string, unknown>;
nested.visible = 'value';
const requestBody = Object.create(null) as Record<string, unknown>;
requestBody.nested = nested;
Object.defineProperty(requestBody, '__proto__', { value: 'safe', enumerable: true });
backend.writeAccess(createAccessRecord(requestBody as AccessLogRecord['requestBody']));
const details = output.mock.calls[0][1] as { requestBody: Record<string, unknown> };
expect(Object.getPrototypeOf(details.requestBody)).toBe(Object.prototype);
expect(Object.getPrototypeOf(details.requestBody.nested)).toBe(Object.prototype);
expect(details.requestBody).toHaveProperty('__proto__', 'safe');
expect(Object.getPrototypeOf(requestBody)).toBe(null);
expect(Object.getPrototypeOf(nested)).toBe(null);
});
});
@@ -0,0 +1,249 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Readable } from 'node:stream';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { LogManager } from '@/logging/LogManager.js';
import type { AccessLogRecord, AccessLogStatusClass } from '@/logging/types.js';
import { registerHttpAccessLog } from '@/server/http-access-log.js';
type TestServer = {
readonly fastify: FastifyInstance;
readonly manager: LogManager;
readonly writeAccess: ReturnType<typeof vi.fn>;
};
type TestManager = {
readonly manager: LogManager;
readonly writeAccess: ReturnType<typeof vi.fn>;
};
/** Access logの動作確認用に固定時刻・プロセス情報を持つManagerを作成します。 */
function createManager(options: {
statusClasses?: AccessLogStatusClass[];
requestBody?: boolean;
responseBody?: boolean;
maxBytes?: number;
nodeEnv?: string;
quiet?: boolean;
} = {}): TestManager {
const writeAccess = vi.fn<(record: AccessLogRecord) => void>();
const manager = new LogManager({ write: vi.fn(), writeAccess }, {
now: () => new Date('2026-07-22T00:00:00.000Z'),
getProcessInfo: () => ({ processId: 123, isPrimary: true, workerId: null }),
isQuiet: () => options.quiet ?? false,
isVerbose: () => false,
getNodeEnv: () => options.nodeEnv ?? 'development',
});
manager.configure({
access: {
statusClasses: options.statusClasses ?? ['2xx', '3xx', '4xx', '5xx'],
bodies: {
request: options.requestBody ?? false,
response: options.responseBody ?? false,
maxBytes: options.maxBytes,
},
},
});
return { manager, writeAccess };
}
/** Access logフックを登録したテスト用Fastifyを作成します。 */
async function createServer(options: Parameters<typeof createManager>[0] = {}): Promise<TestServer> {
const { manager, writeAccess } = createManager(options);
const fastify = Fastify({ logger: false });
registerHttpAccessLog(fastify, manager);
fastify.get('/items/:id', async () => ({ ok: true }));
fastify.get('/bad', async (_request, reply) => reply.code(400).send({ error: 'bad' }));
fastify.get('/fail', async () => {
throw new TypeError('failure');
});
fastify.get('/redirect', async (_request, reply) => reply.redirect('/items/redirect'));
fastify.post('/body', async (request) => ({ echo: request.body, token: 'response-secret' }));
fastify.get('/text', async (_request, reply) => reply.type('text/plain').send('response text'));
fastify.get('/form', async (_request, reply) => reply.type('application/x-www-form-urlencoded').send('i=form-token&password=form-password&visible=yes'));
fastify.get('/binary', async (_request, reply) => reply.type('application/octet-stream').send(Buffer.from('binary')));
fastify.get('/stream', async (_request, reply) => reply.type('text/plain').send(Readable.from(['stream body'])));
await fastify.ready();
return { fastify, manager, writeAccess };
}
const servers: FastifyInstance[] = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => server.close()));
});
describe('registerHttpAccessLog', () => {
test('filters responses by configured status classes and keeps the route template', async () => {
const server = await createServer({ statusClasses: ['4xx', '5xx'] });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/items/secret?id=hidden' });
await server.fastify.inject({ method: 'GET', url: '/bad' });
await server.fastify.inject({ method: 'GET', url: '/fail' });
await server.fastify.inject({ method: 'GET', url: '/missing?token=hidden' });
expect(server.writeAccess).toHaveBeenCalledTimes(3);
expect(server.writeAccess.mock.calls.map(call => call[0])).toEqual(expect.arrayContaining([
expect.objectContaining({ route: '/bad', statusCode: 400 }),
expect.objectContaining({ route: '/fail', statusCode: 500, errorType: 'TypeError' }),
expect.objectContaining({ route: null, statusCode: 404 }),
]));
expect(server.writeAccess.mock.calls[0][0]).not.toHaveProperty('requestUrl');
expect(server.writeAccess.mock.calls.find(call => call[0].statusCode === 404)?.[0]).not.toHaveProperty('errorType');
});
test('records redirects and response size when the status class is selected', async () => {
const server = await createServer({ statusClasses: ['3xx'] });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/redirect' });
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({
method: 'GET',
route: '/redirect',
statusCode: 302,
responseSizeBytes: expect.any(Number),
}));
});
test('captures and redacts JSON request and response bodies in development', async () => {
const server = await createServer({ requestBody: true, responseBody: true });
servers.push(server.fastify);
await server.fastify.inject({
method: 'POST',
url: '/body',
headers: { 'content-type': 'application/json' },
payload: { i: 'request-token', nested: { password: 'request-password' }, value: 'visible' },
});
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({
requestBody: {
i: '[REDACTED]',
nested: { password: '[REDACTED]' },
value: 'visible',
},
responseBody: {
echo: {
i: '[REDACTED]',
nested: { password: '[REDACTED]' },
value: 'visible',
},
token: '[REDACTED]',
},
}));
});
test('captures text but omits binary and stream bodies', async () => {
const server = await createServer({ statusClasses: ['2xx'], responseBody: true });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/text' });
await server.fastify.inject({ method: 'GET', url: '/binary' });
await server.fastify.inject({ method: 'GET', url: '/stream' });
expect(server.writeAccess.mock.calls[0][0]).toHaveProperty('responseBody', 'response text');
expect(server.writeAccess.mock.calls[1][0]).not.toHaveProperty('responseBody');
expect(server.writeAccess.mock.calls[2][0]).not.toHaveProperty('responseBody');
});
test('parses form bodies before redaction', async () => {
const server = await createServer({ statusClasses: ['2xx'], responseBody: true });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/form' });
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({
responseBody: {
i: '[REDACTED]',
password: '[REDACTED]',
visible: 'yes',
},
}));
});
test('truncates normalized bodies to the configured limit', async () => {
const server = await createServer({ requestBody: true, responseBody: true, maxBytes: 1024 });
servers.push(server.fastify);
await server.fastify.inject({
method: 'POST',
url: '/body',
headers: { 'content-type': 'application/json' },
payload: { value: 'x'.repeat(20_000) },
});
const record = server.writeAccess.mock.calls[0][0];
expect(Buffer.byteLength(JSON.stringify(record.requestBody), 'utf8')).toBeLessThanOrEqual(1024);
expect(Buffer.byteLength(JSON.stringify(record.responseBody), 'utf8')).toBeLessThanOrEqual(1024);
});
test('preserves the response payload and reports an unknown stream size', async () => {
const server = await createServer({ statusClasses: ['2xx'], responseBody: true });
servers.push(server.fastify);
const response = await server.fastify.inject({ method: 'GET', url: '/stream' });
expect(response.body).toBe('stream body');
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({ responseSizeBytes: null }));
});
test('does not capture bodies in production and returns a warning', async () => {
const { manager, writeAccess } = createManager({ nodeEnv: 'production', requestBody: true, responseBody: true });
const warnings = manager.configure({ access: { statusClasses: ['2xx'], bodies: { request: true, response: true } } });
const fastify = Fastify({ logger: false });
registerHttpAccessLog(fastify, manager);
fastify.post('/body', async request => ({ body: request.body }));
await fastify.ready();
servers.push(fastify);
await fastify.inject({ method: 'POST', url: '/body', headers: { 'content-type': 'application/json' }, payload: { token: 'hidden' } });
expect(warnings).toEqual(['logging.access.bodies is disabled in production mode']);
expect(writeAccess).toHaveBeenCalledWith(expect.not.objectContaining({ requestBody: expect.anything(), responseBody: expect.anything() }));
});
test('keeps the request Trace Context through response completion', async () => {
const server = await createServer({ statusClasses: ['2xx'] });
servers.push(server.fastify);
const traceContext = { traceId: 'trace', spanId: 'span', traceFlags: 1 };
const provider = vi.fn(() => traceContext);
server.manager.setTraceContextProvider(provider);
await server.fastify.inject({ method: 'GET', url: '/items/trace' });
expect(provider).toHaveBeenCalledOnce();
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining(traceContext));
});
test('omits a Trace Context that was not active at request start', async () => {
const server = await createServer({ statusClasses: ['2xx'] });
servers.push(server.fastify);
const provider = vi.fn()
.mockReturnValueOnce(undefined)
.mockReturnValue({ traceId: 'late-trace', spanId: 'late-span', traceFlags: 1 });
server.manager.setTraceContextProvider(provider);
await server.fastify.inject({ method: 'GET', url: '/items/no-trace' });
expect(server.writeAccess.mock.calls[0][0]).not.toHaveProperty('traceId');
expect(provider).toHaveBeenCalledOnce();
});
test('does not write in quiet mode', async () => {
const server = await createServer({ quiet: true });
servers.push(server.fastify);
const provider = vi.fn(() => ({ traceId: 'trace', spanId: 'span', traceFlags: 1 }));
server.manager.setTraceContextProvider(provider);
await server.fastify.inject({ method: 'GET', url: '/items/quiet' });
expect(provider).not.toHaveBeenCalled();
expect(server.writeAccess).not.toHaveBeenCalled();
});
});
-5
View File
@@ -13,8 +13,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}
-6
View File
@@ -14,9 +14,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TagCanvas: any;
}
+59
View File
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** ドキュメントがアクティブになっていない間は実行を止めるsetInterval。戻り値の関数でdisposeする */
export function createVisibilityAwareInterval(fn: () => void, interval: number, options: {
immediate?: boolean;
} = {}): () => void {
let intervalId: number | null = null;
let lastCalledAt: number | null = null;
const tick = () => {
lastCalledAt = Date.now();
fn();
};
const start = () => {
if (intervalId != null) return;
if (lastCalledAt == null) {
if (options.immediate) {
tick();
} else {
lastCalledAt = Date.now();
}
} else if (Date.now() - lastCalledAt >= interval) {
// もし前回の呼び出しからinterval以上の時間が経過していたら、即座に呼び出す
// (非アクティブ→アクティブに戻った場合など)
tick();
}
intervalId = window.setInterval(tick, interval);
};
const stop = () => {
if (intervalId != null) {
window.clearInterval(intervalId);
intervalId = null;
}
};
const onVisibilityChange = () => {
if (window.document.visibilityState === 'visible') {
start();
} else {
stop();
}
};
window.document.addEventListener('visibilitychange', onVisibilityChange);
if (window.document.visibilityState === 'visible') {
start();
}
return () => {
window.document.removeEventListener('visibilitychange', onVisibilityChange);
stop();
};
}
+26 -13
View File
@@ -4,34 +4,47 @@
*/
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
import { createVisibilityAwareInterval } from './interval.js';
export function useInterval(fn: () => void, interval: number, options: {
immediate: boolean;
afterMounted: boolean;
keepRunningWhenHidden?: boolean;
}): (() => void) | undefined {
if (Number.isNaN(interval)) return;
let intervalId: number | null = null;
let disposer: (() => void) | null = null;
const start = () => {
if (options.keepRunningWhenHidden) {
if (options.immediate) fn();
const intervalId = window.setInterval(fn, interval);
disposer = () => {
window.clearInterval(intervalId);
};
} else {
disposer = createVisibilityAwareInterval(fn, interval, { immediate: options.immediate });
}
};
const clear = () => {
if (disposer) {
disposer();
disposer = null;
}
};
if (options.afterMounted) {
onMounted(() => {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
start();
});
} else {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
start();
}
const clear = () => {
if (intervalId) window.clearInterval(intervalId);
intervalId = null;
};
onActivated(() => {
if (intervalId) return;
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
if (disposer) return;
start();
});
onDeactivated(() => {
-5
View File
@@ -13,8 +13,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -22,6 +22,7 @@
"@mcaptcha/core-glue": "0.1.0-alpha-5",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/tagcanvas-es": "0.1.2",
"@sentry/vue": "10.65.0",
"@simplewebauthn/browser": "13.3.0",
"@syuilo/aiscript": "1.2.1",
@@ -87,7 +87,7 @@ onDeactivated(() => {
useInterval(() => {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (!window.document.hidden && isActivated) {
if (isActivated) {
fetchHistory();
}
}, 1000 * 10, {
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@pointerup.passive="onPointerup"
@pointercancel.passive="cancelPointerGesture"
@touchstart.passive="onTouchstart"
@touchmove.passive="onTouchmove"
@touchmove="onTouchmove"
@touchcancel.passive="cancelPointerGesture"
@contextmenu="cancelPointerGesture"
@wheel="onWheel"
@@ -284,7 +284,7 @@ function shouldHideInGallery(content: Content): boolean {
if (!hiddenByDefault) return false;
// ギャラリー起動時に最初に開いたセンシティブ画像だけは初期表示で隠さない
if (content.file.isSensitive && prefer.s.nsfw !== 'force' && props.initiallyOpened) {
if (content.file.isSensitive && props.initiallyOpened) {
return false;
}
@@ -721,6 +721,13 @@ function onTouchstart(ev: TouchEvent) {
function onTouchmove(ev: TouchEvent) {
doubleTapDetector.onTouchmove(ev);
// スワイプ操作中は、ブラウザがタッチを慣性スクロールとして認識するのを防ぐためにpreventDefaultする必要がある
// touch-action: noneが指定されているが、それだけではブラウザによっては慣性スクロールが発生した扱いになり、
// その後のタップが慣性スクロールを止めるためのタップという扱いで握りつぶされてしまうことがあるので必要
if (isVerticalSwiping || isHorizontalSwiping || isZooming.value || pointerEventCache.size > 1) {
ev.preventDefault();
}
}
//#region inertia
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @pointerup="onClick" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -61,8 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</template>
</div>
</template>
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot" @pointerup="emit('mediaClick', $event)">
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
@@ -41,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-player-play"></i>
</div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</div>
</div>
</template>
@@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<div v-if="appearNote.renoteId" :class="$style.quote"><MkNoteSimple :note="appearNote?.renote ?? null" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
@@ -113,7 +113,6 @@ const showContent = ref(false);
.deleted {
text-align: center;
padding: 8px !important;
margin: 8px 8px 0 8px;
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
@@ -182,6 +182,9 @@ onMounted(() => {
onUnmounted(() => {
if (connection) connection.dispose();
if (scrollContainer != null) {
scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
}
});
defineExpose({
@@ -7,12 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div
ref="rootEl"
:class="[$style.root, {
[$style.enter]: animatingIn,
[$style.enter]: animatingIn && animating && !animatingOut,
[$style.leave]: animatingOut,
[$style.animating]: animating,
}]"
@animationstart="animating = true"
@animationend="animating = false"
@animationend.self="onAnimationEnd"
@animationcancel.self="onAnimationEnd"
>
<div ref="innerEl">
<slot></slot>
@@ -40,7 +40,7 @@ if (!supportsInterpolateSize) {
</script>
<script setup lang="ts">
import { useTemplateRef, onMounted, onBeforeUnmount, ref } from 'vue';
import { useTemplateRef, onMounted, onBeforeUnmount, ref, watch } from 'vue';
const props = defineProps<{
animatingIn?: boolean;
@@ -52,6 +52,16 @@ const innerEl = useTemplateRef('innerEl');
const animating = ref(false);
watch([() => props.animatingIn, () => props.animatingOut], ([animatingIn, animatingOut]) => {
if (animatingIn === true || animatingOut === true) animating.value = true;
}, { immediate: true });
function onAnimationEnd() {
// 削除アニメーション中(enterから差し替わった場合を含む)は、要素が消えるまで再生中扱いのままにする
if (props.animatingOut === true) return;
animating.value = false;
}
onMounted(() => {
if (resizeObserver != null && rootEl.value != null && innerEl.value != null) {
resizeObserver.observe(innerEl.value);
+45 -39
View File
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="$style.root">
<canvas :id="idForCanvas" ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div :id="idForTags" ref="tagsEl" :class="$style.tags">
<canvas ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div ref="tagsEl" :class="$style.tags">
<ul>
<slot></slot>
</ul>
@@ -15,62 +15,68 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import { onMounted, onBeforeUnmount, nextTick, ref, shallowRef, useTemplateRef } from 'vue';
import { themeManager } from '@/theme.js';
import tinycolor from 'tinycolor2';
import { TagCanvas } from '@misskey-dev/tagcanvas-es';
const loaded = !!window.TagCanvas;
const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const available = ref(false);
const rootEl = useTemplateRef('rootEl');
const canvasEl = useTemplateRef('canvasEl');
const tagsEl = useTemplateRef('tagsEl');
const tagCanvas = shallowRef<TagCanvas | null>(null);
const width = ref(300);
watch(available, () => {
try {
window.TagCanvas.Start(idForCanvas, idForTags, {
textColour: '#ffffff',
outlineColour: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
//dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
} catch (err) {}
});
function createTagCanvas() {
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
if (tagsEl.value == null || canvasEl.value == null) return;
if (tagsEl.value.children[0].children.length === 0) return;
tagCanvas.value = new TagCanvas(canvasEl.value, {
tagContainer: tagsEl.value,
textColor: '#ffffff',
outlineColor: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
// dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
}
onMounted(() => {
if (rootEl.value) width.value = rootEl.value.offsetWidth;
if (loaded) {
available.value = true;
} else {
window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
async: true,
src: '/client-assets/tagcanvas.min.js',
})).addEventListener('load', () => available.value = true);
}
nextTick(() => {
createTagCanvas();
});
});
onBeforeUnmount(() => {
if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
});
defineExpose({
update: () => {
window.TagCanvas.Update(idForCanvas);
if (tagCanvas.value) {
tagCanvas.value.update();
} else {
createTagCanvas();
}
},
});
</script>
@@ -37,29 +37,41 @@ const observer = new ResizeObserver((entries) => {
</script>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { onBeforeUnmount, useTemplateRef, watch } from 'vue';
const props = withDefaults(defineProps<Props>(), {
minScale: 0,
});
const content = ref<HTMLSpanElement>();
const content = useTemplateRef('content');
function unobserve(el: HTMLSpanElement | null) {
if (el != null) {
delete (el as any)[contentSymbol];
observer.unobserve(el);
if (el.parentElement) {
observer.unobserve(el.parentElement);
}
}
}
function observe(el: HTMLSpanElement | null) {
if (el != null) {
(el as any)[contentSymbol] = props;
observer.observe(el);
if (el.parentElement) {
observer.observe(el.parentElement);
}
}
}
watch(content, (value, oldValue) => {
if (oldValue != null) {
delete (oldValue as any)[contentSymbol];
observer.unobserve(oldValue);
if (oldValue.parentElement) {
observer.unobserve(oldValue.parentElement);
}
}
if (value != null) {
(value as any)[contentSymbol] = props;
observer.observe(value);
if (value.parentElement) {
observer.observe(value.parentElement);
}
}
unobserve(oldValue);
observe(value);
});
onBeforeUnmount(() => {
unobserve(content.value);
});
</script>
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js';
import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
import type { GridContext, GridEvent } from '@/components/grid/grid-event.js';
@@ -119,6 +119,10 @@ const { data } = toRefs(props);
* 子コンポーネント -> gridのイベントでは原則使用せず、{@link emit}を使用する。
*/
const bus = new GridEventEmitter();
/**
* {@link resizeObserver}が発行した、まだ実行されていない{@link setTimeout}のID。アンマウント時にまとめて破棄するために保持する。
*/
const resizeTimeoutIds = new Set<number>();
/**
* テーブルコンポーネントのリサイズイベントを監視するための{@link ResizeObserver}。
* 表示切替を検知し、サイズの再計算要求を発行するために使用する(マウント時にコンテンツが表示されていない場合、初手のサイズの自動計算が正常に働かないため)
@@ -128,7 +132,13 @@ const bus = new GridEventEmitter();
*
* @see {@link onResize}
*/
const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries)));
const resizeObserver = new ResizeObserver((entries) => {
const timeoutId = window.setTimeout(() => {
resizeTimeoutIds.delete(timeoutId);
onResize(entries);
});
resizeTimeoutIds.add(timeoutId);
});
const rootEl = useTemplateRef('rootEl');
/**
@@ -1265,6 +1275,18 @@ onMounted(() => {
refreshData();
});
onUnmounted(() => {
resizeObserver.disconnect();
for (const timeoutId of resizeTimeoutIds) {
window.clearTimeout(timeoutId);
}
resizeTimeoutIds.clear();
// 選択操作の途中でアンマウントされた場合、windowに登録したままのリスナーが残ってしまうので解除しておく
unregisterMouseMove();
unregisterMouseUp();
});
</script>
<style module lang="scss">
@@ -4,6 +4,7 @@
*/
import { ref, readonly, computed } from 'vue';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
const time = ref(Date.now());
@@ -29,6 +30,6 @@ export function useLowresTime() {
return computed(() => Math.max(time.value, now));
}
window.setInterval(() => {
createVisibilityAwareInterval(() => {
time.value = Date.now();
}, TIME_UPDATE_INTERVAL);
@@ -6,6 +6,7 @@
import { onUnmounted, reactive } from 'vue';
import * as Misskey from 'misskey-js';
import { EventEmitter } from 'eventemitter3';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import type { Reactive } from 'vue';
import type { NoteUpdatedEvent } from 'misskey-js/streaming.types.js';
import { useStream } from '@/stream.js';
@@ -68,7 +69,8 @@ const POLLING_INTERVAL =
prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
MIN_POLLING_INTERVAL;
window.setInterval(() => {
// documentが非表示の間はポーリングを停止する
createVisibilityAwareInterval(() => {
const ids = [...pollingQueue.entries()]
.filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
.map(([k, v]) => k)
@@ -76,7 +78,6 @@ window.setInterval(() => {
.slice(0, CAPTURE_MAX);
if (ids.length === 0) return;
if (window.document.hidden) return;
// まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない?
misskeyApi('notes/show-partial-bulk', {
@@ -29,7 +29,7 @@ export const clickAnimeDirective = {
target.addEventListener('mouseleave', () => {
target.classList.remove('_anime_bounce_ready');
});
}, { once: true, signal: abortController.signal });
}, { signal: abortController.signal });
el.addEventListener('click', () => {
@@ -303,7 +303,11 @@ if (!props.game.isEnded) {
props.connection!.send('claimTimeIsUp', {});
}
}
}, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true });
}, TIMER_INTERVAL_SEC * 1000, {
immediate: false,
afterMounted: true,
keepRunningWhenHidden: true, // 対局の制限時間管理のため、バックグラウンドでも止めない
});
}
async function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) {
@@ -248,7 +248,11 @@ async function accept(user: Misskey.entities.UserLite) {
}
}
useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true });
useInterval(matchHeatbeat, 1000 * 5, {
immediate: false,
afterMounted: true,
keepRunningWhenHidden: true, // バックグラウンドタブでもマッチング待機を維持する必要がある
});
onMounted(() => {
misskeyApi('reversi/invitations').then(_invitations => {
+3 -2
View File
@@ -4,6 +4,7 @@
*/
import { BroadcastChannel } from 'broadcast-channel';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import type { StorageProvider } from '@/preferences/manager.js';
import { cloudBackup } from '@/preferences/utility.js';
import { miLocalStorage } from '@/local-storage.js';
@@ -143,10 +144,10 @@ preferencesChannel.addEventListener('message', (msg) => {
//#region 定期クラウドバックアップ
let latestBackupAt = 0;
window.setInterval(() => {
// documentが非表示の間は実行されない (同期されていない古い値がバックアップされるのを防ぐ意味もある)
createVisibilityAwareInterval(() => {
if ($i == null) return;
if (!store.s.enablePreferencesAutoCloudBackup) return;
if (window.document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
if (prefer.profile.modifiedAt <= latestBackupAt) return;
cloudBackup().then(() => {
+2 -1
View File
@@ -411,7 +411,8 @@ export class Paginator<
public releaseQueue(): void {
if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
for (const item of this.aheadQueue) {
item._shouldAnimateIn_ = true;
item._shouldAnimateIn_ = false; // 一気に入るときは挿入アニメーションさせない
item._shouldAnimateOut_ = false;
}
this.unshiftItems(this.aheadQueue);
this.aheadQueue = [];
+5
View File
@@ -239,6 +239,11 @@ export async function getSoundDuration(file: string): Promise<number> {
audioEl.remove();
}
}, 100);
audioEl.addEventListener('error', () => {
resolve(0);
window.clearInterval(si);
audioEl.remove();
}, { once: true });
});
}
+4 -6
View File
@@ -75,11 +75,9 @@ const fetchEndpoint = computed(() => {
url.searchParams.set('url', widgetProps.url);
return url.toString();
});
const intervalClear = ref<(() => void) | undefined>();
let intervalClear: (() => void) | null | undefined = null;
const tick = () => {
if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
@@ -90,10 +88,10 @@ const tick = () => {
watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();
if (intervalClear != null) {
intervalClear();
}
intervalClear.value = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
immediate: true,
afterMounted: true,
});
@@ -113,13 +113,11 @@ const fetchEndpoint = computed(() => {
url.searchParams.set('url', widgetProps.url);
return url;
});
const intervalClear = ref<(() => void) | undefined>();
let intervalClear: (() => void) | null | undefined = null;
const key = ref(0);
const tick = () => {
if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
@@ -131,10 +129,10 @@ const tick = () => {
watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();
if (intervalClear != null) {
intervalClear();
}
intervalClear.value = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
intervalClear = useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
immediate: true,
afterMounted: true,
});
@@ -17,6 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
@@ -59,7 +60,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
let intervalId: number | null = null;
let disposeInterval: (() => void) | null = null;
let rafRequestId: number | null = null;
const ss = ref('');
const ms = ref('');
@@ -83,9 +84,9 @@ const tick = () => {
};
const clearTimers = () => {
if (intervalId) {
window.clearInterval(intervalId);
intervalId = null;
if (disposeInterval) {
disposeInterval();
disposeInterval = null;
}
if (rafRequestId) {
window.cancelAnimationFrame(rafRequestId);
@@ -99,12 +100,13 @@ watch(() => widgetProps.showMs, (to) => {
clearTimers();
if (to) {
// rafはdocumentが非表示の間はブラウザによって自動的に停止される
rafRequestId = window.requestAnimationFrame(function loop() {
tick();
rafRequestId = window.requestAnimationFrame(loop);
});
} else {
intervalId = window.setInterval(tick, 1000);
disposeInterval = createVisibilityAwareInterval(tick, 1000);
}
}, { immediate: true });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.7.0-beta.2",
"version": "2026.7.0-beta.5",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
+8
View File
@@ -673,6 +673,9 @@ importers:
'@misskey-dev/emoji-data':
specifier: 17.0.3
version: 17.0.3
'@misskey-dev/tagcanvas-es':
specifier: 0.1.2
version: 0.1.2
'@sentry/vue':
specifier: 10.65.0
version: 10.65.0(vue@3.5.39(typescript@6.0.2))
@@ -2447,6 +2450,9 @@ packages:
'@misskey-dev/summaly@5.5.1':
resolution: {integrity: sha512-CfpVtmFgltjNS1FAS5+bOluKAF/irZuQnUxAw66ydnp/uesiNNu62ltf9gEgwutT5S+itGtlnP87AOx6DDn/1A==}
'@misskey-dev/tagcanvas-es@0.1.2':
resolution: {integrity: sha512-SGqyu30/CCQyIC95Zcmj3k1vYXwfX66/arsxpl1UUulUqvlRpTpuhB7kEAAps1lqXin0rQzXknb31EeveNKsaw==}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
@@ -10505,6 +10511,8 @@ snapshots:
optionalDependencies:
fastify: 5.8.5
'@misskey-dev/tagcanvas-es@0.1.2': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true