diff --git a/.agents/skills/shipping-misskey-change/SKILL.md b/.agents/skills/shipping-misskey-change/SKILL.md index 700e6825e12..ea7ab5b6864 100644 --- a/.agents/skills/shipping-misskey-change/SKILL.md +++ b/.agents/skills/shipping-misskey-change/SKILL.md @@ -1,6 +1,6 @@ --- name: shipping-misskey-change -description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when validation, SPDX, locale safety, migrations, misskey-js generation, or CHANGELOG checks may apply. +description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when proportional validation, SPDX, locale safety, migrations, or misskey-js generation may apply. --- # shipping-misskey-change diff --git a/.claude/THIRD_PARTY_LICENSES.md b/.claude/THIRD_PARTY_LICENSES.md index 0fc5df629c6..f36e665f7b3 100644 --- a/.claude/THIRD_PARTY_LICENSES.md +++ b/.claude/THIRD_PARTY_LICENSES.md @@ -18,8 +18,8 @@ | `.claude/` 内のパス | 上流パス | 上流由来 | Misskey での改変 | |---|---|---|---| | `skills/context-budget/SKILL.md` | `skills/context-budget/SKILL.md` | ECC | description を日本語化、Misskey 固有メモを追記 | -| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、Claude が `pnpm`/`git`/`grep` で手動採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み | -| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsgo + ESLint + Vitest) に。Prettier/Biome フェーズを削除 | +| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、repository-native command と SPDX checker で採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み | +| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsc + ESLint + Vitest) に。Prettier/Biome フェーズを削除 | ### MIT License (full text) diff --git a/.claude/agents/README.md b/.claude/agents/README.md index cf1032d6325..02faf2a6df7 100644 --- a/.claude/agents/README.md +++ b/.claude/agents/README.md @@ -10,7 +10,8 @@ frontmatter (`name` + `description` + `tools`) は、Claude が **自動でエ レビュー面を増やしすぎないよう、役割を分ける: -- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・SPDX 形式・Storybook 併設 等)。別コンテキストで差分を機械走査する価値がある領域に限定する +- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・Storybook 併設 等)。 + 別コンテキストで差分を機械走査する価値がある領域に限定する - **`pr-review-toolkit` プラグイン (code-reviewer / silent-failure-hunter 等)**: 言語非依存の一般的なコード品質・バグ・設計レビュー。Misskey 固有規約は見ない - **`working-on-*` skill の checklist**: コードを **書いている最中** の自己チェック (レビュー専用ではなく実装ガイド) diff --git a/.claude/agents/misskey-api-reviewer.md b/.claude/agents/misskey-api-reviewer.md index 0d02c9fbffd..51092de15ea 100644 --- a/.claude/agents/misskey-api-reviewer.md +++ b/.claude/agents/misskey-api-reviewer.md @@ -1,6 +1,6 @@ --- name: misskey-api-reviewer -description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID/SPDX を検査。backend API を変更した PR レビューで呼ぶ。 +description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID を検査。backend API を変更した PR レビューで呼ぶ。 tools: Read, Grep, Glob, Bash --- @@ -30,26 +30,12 @@ BASE=$(git merge-base origin/develop HEAD) - `packages/backend/src/server/api/endpoint-list.ts` - `packages/backend/test/e2e/**` (とくに `endpoints.ts` と `.ts`) - `packages/misskey-js/src/autogen/**` -- `CHANGELOG.md` 差分対象が空なら「レビュー対象の API エンドポイント変更なし」と短く報告して終了。 ## チェックリスト -### 1. SPDX ヘッダー (Critical) - -新規 `.ts` ファイル冒頭に以下があるか: - -``` -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ -``` - -欠落すると CI の `spdx` ジョブが落ちる。 - -### 2. `meta` の必須・推奨フィールド (Major) +### 1. `meta` の必須・推奨フィールド (Major) [endpoints.ts の型定義](../../packages/backend/src/server/api/endpoints.ts) を真とする。 @@ -63,7 +49,7 @@ BASE=$(git merge-base origin/develop HEAD) - `res`: JSON Schema または `ref: ''`。各プロパティに `optional` / `nullable` が **明示** されているか。 - `requireFile` / `secure` / `allowGet` / `cacheSec` / `description`: 該当するエンドポイントで使い分けているか。 -### 3. `meta.errors` の UUID 検証 (Critical) +### 2. `meta.errors` の UUID 検証 (Critical) 各 `errors[*].id` が: @@ -78,14 +64,14 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint 新規エンドポイントの全 `id` を抽出して衝突を確認する。 -### 4. `paramDef` (Major) +### 3. `paramDef` (Major) - JSON Schema 形式 (`type: 'object'`, `properties`, `required`) - ID 文字列は `format: 'misskey:id'` - `required` 配列で必須プロパティを明示 - `as const` または `as const satisfies Schema` で型推論を効かせる (既存実装は前者多数。`as const` 自体が無く `Schema` 型注釈もない場合のみ指摘) -### 5. エンドポイント実装本体 (Major) +### 4. エンドポイント実装本体 (Major) - `Endpoint` を継承しているか。 - `@Injectable()` デコレータ + `export default class` 形式か (`// eslint-disable-line import/no-default-export` が必要)。 @@ -94,7 +80,7 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint - 防御的アサーション・「起きるはずがない」内部不整合・テスト用 ENV ガード等の **想定外フェイルファスト** は `throw new Error('...')` で構わない。既存実装でも `admin/reset-password.ts` などが採用しているパターン (例: `cannot reset password of root`)。`meta.errors` に対応がない `throw new Error` を一律で指摘しない。 - 同期 `throw` は許容。非同期処理での例外伝搬を確認する。 -### 6. ★ `endpoint-list.ts` への登録 (Critical) +### 5. ★ `endpoint-list.ts` への登録 (Critical) 最も忘れやすい。**忘れると 404**。[endpoint-list.ts](../../packages/backend/src/server/api/endpoint-list.ts) に 1 行追加されているか: @@ -110,7 +96,7 @@ grep -F "'/'" packages/backend/src/server/api/endpoint-list.ts **並び順の補足**: ファイル全体は厳密なアルファベット順では並んでおらず、同カテゴリ内 (`admin/queue/*` など) でも追加された経緯どおりの順になっている箇所が多い。**順序逸脱は指摘根拠にしない** (誤検知の元)。「行が存在するか」のみを Critical 観点として扱う。 -### 7. `misskey-js` 再生成 (Critical) +### 6. `misskey-js` 再生成 (Critical) `meta` / `paramDef` / `res` を変更したら、PR / ブランチに `packages/misskey-js/src/autogen/` 配下の差分が含まれているか確認する: @@ -121,22 +107,12 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/ 差分ゼロなら `pnpm build-misskey-js-with-types` の実行漏れ。CI の `check-misskey-js-autogen` ワークフローで必ず落ちるため Critical 扱い。 -### 8. e2e テスト (Major) +### 7. e2e テスト (Major) [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) または `test/e2e/.ts` (`note.ts`, `users.ts` 等) 配下に、対応する `api('/', ...)` 呼び出しを含む `test(...)` ケースが追加されているか確認する。複雑な分岐 (権限チェック・エラーケース) の網羅も確認する。 **describe ラベルの形式は問わない**: 既存テストは `describe('Note', () => { test('投稿できる', ...) })` のように人間可読ラベルで構造化されており、`/` 形式の describe は使われていない。describe 名の規約違反としては指摘しない。 -### 9. CHANGELOG エントリ (Minor) - -ユーザー影響がある (新エンドポイント / 既存挙動変更) 場合、`CHANGELOG.md` の `## Unreleased` → `### Server` に 1 行追加されているか確認する。 - -``` -- Feat: /api// を追加 -``` - -純粋な内部リファクタなら不要。 - ## 出力形式 優先度別に以下のフォーマットで出力する。 @@ -166,4 +142,4 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/ - [endpoint-base.ts (Endpoint 基底クラス)](../../packages/backend/src/server/api/endpoint-base.ts) - [error.ts (ApiError)](../../packages/backend/src/server/api/error.ts) - [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) -- [AGENTS.md](../../AGENTS.md) — SPDX / マイグレーション履歴 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通) +- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針 diff --git a/.claude/agents/vue-component-reviewer.md b/.claude/agents/vue-component-reviewer.md index e5788ff68c0..bd6e2a629a7 100644 --- a/.claude/agents/vue-component-reviewer.md +++ b/.claude/agents/vue-component-reviewer.md @@ -1,6 +1,6 @@ --- name: vue-component-reviewer -description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。SPDX (HTML コメント)・Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。 +description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。 tools: Read, Grep, Glob, Bash --- @@ -29,40 +29,26 @@ BASE=$(git merge-base origin/develop HEAD) - `locales/*.yml` (とくに `ja-JP.yml` 以外の変更は即 Critical) - `packages/frontend/src/components/**/*.stories.impl.ts` -- `CHANGELOG.md` 差分対象が空なら「レビュー対象の Vue コンポーネント変更なし」と短く報告して終了。 ## チェックリスト -### 1. SPDX ヘッダー (Critical) - -`.vue` ファイル冒頭は **HTML コメント形式** で必須: - -```html - -``` - -`/* ... */` (TS 形式) は禁止 (CI の `spdx` ジョブはコメント形式ではなく SPDX 文字列の有無のみを検査するため、形式が違っても CI は通るが、規約違反として指摘する)。形式の根拠は references/knowledge 側を参照。 - -### 2. 命名規約 (Major) +### 1. 命名規約 (Major) - 共有 / 再利用コンポーネント (`packages/frontend/src/components/` 配下、サブディレクトリ含む) は `Mk` プレフィックス必須 (例: `MkButton.vue`, `global/MkAvatar.vue`, `grid/MkGrid.vue`)。 - ページ固有のものは `pages/` 配下に置き、`Mk` プレフィックスは不要。 **補足:** `'; + + const html = renderBrowserDiagnosticsHtml(base, head); + + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); +}); diff --git a/packages-private/diagnostics-frontend/test/browser/server.test.ts b/packages-private/diagnostics-frontend/test/browser/server.test.ts new file mode 100644 index 00000000000..24f3e624449 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/server.test.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, expect, test, vi } from 'vitest'; +import { stopServer, waitForServer } from '../../src/browser/server'; +import type { ChildProcess } from 'node:child_process'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawnSync: vi.fn(), + }; +}); + +function signaledChild() { + return Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: 'SIGTERM' as NodeJS.Signals, + pid: undefined, + kill: vi.fn(), + }) as unknown as ChildProcess; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('waitForServer fails immediately when the server exited by signal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200 }); + vi.stubGlobal('fetch', fetchMock); + + await expect(waitForServer('http://127.0.0.1:61812', signaledChild())) + .rejects.toThrow('Misskey server exited early with signal SIGTERM'); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test('stopServer returns immediately when the server already exited by signal', async () => { + const child = signaledChild(); + const stopPromise = stopServer(child); + + expect(child.listenerCount('exit')).toBe(0); + await stopPromise; +}); diff --git a/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json new file mode 100644 index 00000000000..27f6b2d6b41 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json new file mode 100644 index 00000000000..ecddcae8128 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts new file mode 100644 index 00000000000..12ec8d7bb11 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { fileExists } from '../../src/bundle/fs-utils'; + +function fsError(code: string) { + return Object.assign(new Error(`fs error: ${code}`), { code }) as NodeJS.ErrnoException; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('fileExists returns false for ENOENT', async () => { + vi.spyOn(fs, 'access').mockRejectedValueOnce(fsError('ENOENT')); + + await expect(fileExists('missing')).resolves.toBe(false); +}); + +test('fileExists rethrows errors other than ENOENT', async () => { + const error = fsError('EACCES'); + vi.spyOn(fs, 'access').mockRejectedValueOnce(error); + + await expect(fileExists('restricted')).rejects.toBe(error); +}); diff --git a/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts new file mode 100644 index 00000000000..ed91120f5f8 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectBundleReport } from '../../src/bundle/manifest'; + +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-test-')); +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('fails loudly when the built output is missing', async () => { + await expect(collectBundleReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +}); diff --git a/packages-private/diagnostics-frontend/test/report.test.ts b/packages-private/diagnostics-frontend/test/report.test.ts new file mode 100644 index 00000000000..92e037e674e --- /dev/null +++ b/packages-private/diagnostics-frontend/test/report.test.ts @@ -0,0 +1,309 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectBundleReport } from '../src/bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from '../src/report'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from '../src/browser/types'; +import type { VisualizerReport } from '../src/bundle/visualizer'; + +const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures'); +const browserFixturesDir = join(import.meta.dirname, 'browser/fixtures'); + +/** + * ビルド成果物のfixture。 + * + * `collectBundleReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の + * 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず + * (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。 + */ +const manifest = { + 'src/_boot_.ts': { file: 'assets/boot-a1.js', src: 'src/_boot_.ts', name: 'boot', isEntry: true, imports: ['_vue.js', '_i18n.js'] }, + '_vue.js': { file: 'assets/vue-b2.js', name: 'vue' }, + // `scripts/` 配下はロケール別に出力されるので ja-JP/ に解決される + '_i18n.js': { file: 'scripts/i18n-c3.js', name: 'i18n' }, + 'src/pages/foo.vue': { file: 'assets/foo-d4.js', src: 'src/pages/foo.vue', name: 'foo' }, + // .js 以外はチャンクとして数えない + 'src/pages/style.css': { file: 'assets/style-e5.css', src: 'src/pages/style.css' }, +}; + +const fileSizes = { + base: { + 'assets/boot-a1.js': 20_000, + 'assets/vue-b2.js': 90_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + 'ja-JP/orphan.js': 1_200, + }, + head: { + // 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される + 'assets/boot-a1.js': 20_003, + // 明確に増えるので diff表に行として出る + 'assets/vue-b2.js': 96_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + // manifestに載らない出力なので「(other generated chunks)」に集約される + 'ja-JP/orphan.js': 1_500, + }, +} as const satisfies Record<'base' | 'head', Record>; + +let repoDirs: { base: string; head: string }; +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-report-test-')); + + for (const label of ['base', 'head'] as const) { + const outDir = join(workDir, label, 'built/_frontend_vite_'); + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest)); + + for (const [file, size] of Object.entries(fileSizes[label])) { + const path = join(outDir, file); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, 'x'.repeat(size)); + } + } + + repoDirs = { + base: join(workDir, 'base'), + head: join(workDir, 'head'), + }; +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +async function loadBundleStats(name: 'base' | 'head') { + return JSON.parse(await readFile(join(bundleFixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +} + +async function loadBrowserReport(name: 'base' | 'head') { + return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +type BrowserReports = { + base: BrowserMetricsReport; + head: BrowserMetricsReport; +}; + +async function renderReport(detailedHtmlUrl: string | null, reports?: BrowserReports) { + const base = reports?.base ?? await loadBrowserReport('base'); + const head = reports?.head ?? await loadBrowserReport('head'); + + return renderFrontendDiagnosticsMarkdown({ + bundle: { + base: await collectBundleReport(repoDirs.base), + head: await collectBundleReport(repoDirs.head), + baseStats: await loadBundleStats('base'), + headStats: await loadBundleStats('head'), + visualizerArtifactUrl: 'https://example.invalid/treemap', + }, + browser: { + base, + head, + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + detailedHtmlUrl, + }, + }); +} + +function withMetricSamples( + report: BrowserMetricsReport, + values: number[], + setValue: (sample: BrowserMeasurementSample, value: number) => void, +): BrowserMetricsReport { + const cloned = structuredClone(report); + const samples = values.map((value, index) => { + const sample = structuredClone(cloned.samples[index % cloned.samples.length]); + sample.round = index + 1; + setValue(sample, value); + return sample; + }); + + return { + ...cloned, + sampleCount: samples.length, + samples, + }; +} + +function requireMetricRow(markdown: string, label: string) { + const row = markdown.split('\n').find(line => line.startsWith(`| **${label}** |`)); + if (row == null) throw new Error(`Metric row not found: ${label}`); + return row; +} + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders one frontend diagnostics markdown report from bundle and browser data', async () => { + 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('Requests by resource type'); + expect(markdown).toContain('## 📦 Bundle Stats'); +}); + +test('omits the browser details link when no detailed html artifact was uploaded', async () => { + const markdown = await renderReport(null); + + expect(markdown).not.toContain('View details'); +}); + +test('renders the difference of independent medians instead of the paired median delta', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100, 100, 100, 1_000, 1_000], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [0, 0, 200, 200, 200], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); + + expect(row).toContain('100
±\u00A00'); + expect(row).toContain('200
±\u00A00'); + expect(row).toContain('$\\color{orange}{\\text{+100}}$
$\\color{orange}{\\text{+100\\\\%}}$'); + expect(row).toContain('| 0 |'); + expect(row.split('|')).toHaveLength(7); + expect(row).not.toMatch(/increase|decrease|within noise|inconclusive/); + expect(row).not.toContain('\\color{green}'); +}); + +test('hides a threshold-sized change that remains within observed noise', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100, 110, 120], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [110, 120, 130], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const markdown = await renderReport(null, { base, head }); + + expect(markdown).not.toContain('| **Requests** |'); +}); + +test('renders a directional request count delta at the absolute threshold', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100, 100, 100], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [101, 101, 101], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); + + expect(row).toContain('$\\color{orange}{\\text{+1}}$'); +}); + +test('hides a directional byte change below the existing absolute threshold', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [1_000_000, 1_000_000, 1_000_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [1_005_000, 1_005_000, 1_005_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + + const markdown = await renderReport(null, { base, head }); + + expect(markdown).not.toContain('| **Encoded network** |'); +}); + +test('renders a directional encoded byte delta at the absolute threshold', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [1_000_000, 1_000_000, 1_000_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [1_010_000, 1_010_000, 1_010_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network'); + + expect(row).toContain('$\\color{orange}{\\text{+10\u00A0KB}}$'); +}); + +test('colours absolute and relative deltas together when the row is significant', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100_000_000, 100_000_000, 100_000_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [100_020_000, 100_020_000, 100_020_000], + (sample, value) => { sample.network.totalEncodedBytes = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network'); + + expect(row).toContain('100\u00A0MB
±\u00A00\u00A0B'); + expect(row).toContain('$\\color{orange}{\\text{+20\u00A0KB}}$
$\\color{orange}{\\text{+0\\\\%}}$'); + expect(row).toContain('| 0\u00A0B |'); + expect(row.split('|')).toHaveLength(7); +}); + +test('renders an unavailable relative delta when the base median is zero', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [0, 0, 0], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [2, 2, 2], + (sample, value) => { sample.network.requestCount = value; }, + ); + + const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); + + expect(row).toContain('$\\color{orange}{\\text{+2}}$
-'); + expect(row).not.toContain('increase'); +}); + +test('throws for a metric with fewer than two samples on one side', async () => { + const base = withMetricSamples( + await loadBrowserReport('base'), + [100], + (sample, value) => { sample.network.requestCount = value; }, + ); + const head = withMetricSamples( + await loadBrowserReport('head'), + [102, 102], + (sample, value) => { sample.network.requestCount = value; }, + ); + + await expect(renderReport(null, { base, head })) + .rejects.toThrow('At least two samples per side are required'); +}); diff --git a/packages-private/diagnostics-frontend/tsconfig.json b/packages-private/diagnostics-frontend/tsconfig.json new file mode 100644 index 00000000000..6672c40acb3 --- /dev/null +++ b/packages-private/diagnostics-frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-shared/eslint.config.js b/packages-private/diagnostics-shared/eslint.config.js new file mode 100644 index 00000000000..11bc648243d --- /dev/null +++ b/packages-private/diagnostics-shared/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-shared/package.json b/packages-private/diagnostics-shared/package.json new file mode 100644 index 00000000000..77deb88bd5c --- /dev/null +++ b/packages-private/diagnostics-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "diagnostics-shared", + "private": true, + "type": "module", + "exports": { + "./env": "./src/env.ts", + "./format": "./src/format.ts", + "./html": "./src/html.ts", + "./stats": "./src/stats.ts", + "./metric-table": "./src/metric-table.ts", + "./heap-snapshot": "./src/heap-snapshot/index.ts" + }, + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "26.2.0", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-shared/src/env.ts b/packages-private/diagnostics-shared/src/env.ts new file mode 100644 index 00000000000..0875cb23739 --- /dev/null +++ b/packages-private/diagnostics-shared/src/env.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function readIntegerEnv(name: string, defaultValue: number, min: number) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); + + const value = Number(rawValue); + if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); + return value; +} + +export function readBooleanEnv(name: string, defaultValue: boolean) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (rawValue === '1' || rawValue === 'true') return true; + if (rawValue === '0' || rawValue === 'false') return false; + throw new Error(`${name} must be one of: 1, 0, true, false`); +} + +/** + * 必須の環境変数を読む。未設定・空文字ならエラーにする。 + */ +export function readRequiredEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') throw new Error(`${name} must be set`); + return rawValue; +} + +/** + * 任意の環境変数を読む。未設定・空文字なら null を返す。 + */ +export function readOptionalEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') return null; + return rawValue; +} diff --git a/packages-private/diagnostics-shared/src/format.ts b/packages-private/diagnostics-shared/src/format.ts new file mode 100644 index 00000000000..1278c858416 --- /dev/null +++ b/packages-private/diagnostics-shared/src/format.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const numberFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, +}); + +export function escapeLatex(text: string) { + return text + .replaceAll('\\', '\\\\') + .replaceAll('{', '\\{') + .replaceAll('}', '\\}') + .replaceAll('%', '\\%'); +} + +export function escapeMdTableCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + +export function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); +} + +export function formatNumber(value: number) { + return numberFormatter.format(value); +} + +export function formatBytes(value: number) { + if (value === 0) return '0\u00A0B'; // nbspにすること + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = value; + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000; + unitIndex += 1; + } + + const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; + // eslint-disable-next-line no-irregular-whitespace + return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))}\u00A0${units[unitIndex]}`; // nbspにすること +} + +/** + * KiB単位の値をMB表記にする。/proc 由来の値の表示に使う。 + */ +export function formatKiBAsMb(valueKiB: number | null | undefined) { + if (valueKiB == null || !Number.isFinite(valueKiB)) return '-'; + return `${formatNumber(valueKiB / 1000)} MB`; +} + +export function formatPercent(value: number) { + return `${formatNumber(value)}%`; +} + +export function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; + return `${formatNumber(value)} ms`; +} + +export function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + +/** + * 差分値を符号付きで整形する。`colorThreshold` 以上の変化があるときだけ色を付ける。 + */ +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))).replaceAll(' ', '~')}}$`; // "1 B"とかで1とBの間で自動改行させないためnbsp(~)にする + const color = delta > 0 ? 'orange' : 'green'; + return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta))).replaceAll(' ', '~')}}}$`; // "1 B"とかで1とBの間で自動改行させないためnbsp(~)にする +} + +export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { + return formatColoredDelta(deltaBytes, formatBytes, colorThreshold); +} + +export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { + return formatColoredDelta(deltaPercent, formatPercent, colorThreshold); +} + +/** + * Markdownのテーブルセル内に置く差分パーセント。 + * LaTeX由来の `\%` がMarkdownのエスケープで食われるため二重エスケープする。 + */ +export function formatDeltaPercentInMdTable(deltaPercent: number, colorThreshold = 0) { + return formatDeltaPercent(deltaPercent, colorThreshold).replaceAll('\\%', '\\\\%'); +} + +export function calcAndFormatDeltaNumber(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatColoredDelta(after - before, formatNumber, colorThreshold); +} + +export function calcAndFormatDeltaBytes(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatDeltaBytes(after - before, colorThreshold); +} + +export function calcAndFormatDeltaPercent(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || before === 0 || after == null) return '-'; + return formatDeltaPercent((after - before) / before * 100, colorThreshold); +} + +export function calcAndFormatDeltaPercentInMdTable(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + return calcAndFormatDeltaPercent(before, after, colorThreshold).replaceAll('\\%', '\\\\%'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts new file mode 100644 index 00000000000..b3443e1889c --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { classifyHeapSnapshotBreakdown, collapseHeapSnapshotBreakdowns } from './breakdown'; +import { + createEmptyHeapSnapshotData, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +/** + * `.heapsnapshot` のJSONを、フィールドオフセットを解決した状態で扱うためのビュー。 + */ +type HeapSnapshotView = { + nodes: number[]; + edges: number[]; + strings: string[]; + nodeFieldCount: number; + edgeFieldCount: number; + nodeTypeNames: string[]; + edgeTypeNames: string[]; + typeOffset: number; + nameOffset: number; + selfSizeOffset: number; + edgeCountOffset: number; + edgeTypeOffset: number; + edgeNameOffset: number; + edgeToNodeOffset: number; + extraNativeBytes: number; +}; + +function requireOffsets(fields: string[], names: string[], what: string) { + const offsets = names.map(name => fields.indexOf(name)); + if (offsets.some(offset => offset < 0)) throw new Error(`Heap snapshot is missing required ${what} fields`); + return offsets; +} + +function parseHeapSnapshot(snapshot: any): HeapSnapshotView { + const meta = snapshot?.snapshot?.meta; + const { nodes, edges, strings } = snapshot ?? {}; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + const edgeFields = meta.edge_fields; + if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); + + const [typeOffset, nameOffset, selfSizeOffset, edgeCountOffset] = requireOffsets(nodeFields, ['type', 'name', 'self_size', 'edge_count'], 'node'); + const [edgeTypeOffset, edgeNameOffset, edgeToNodeOffset] = requireOffsets(edgeFields, ['type', 'name_or_index', 'to_node'], 'edge'); + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; + if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); + + return { + nodes, + edges, + strings, + nodeFieldCount: nodeFields.length, + edgeFieldCount: edgeFields.length, + nodeTypeNames, + edgeTypeNames, + typeOffset, + nameOffset, + selfSizeOffset, + edgeCountOffset, + edgeTypeOffset, + edgeNameOffset, + edgeToNodeOffset, + extraNativeBytes: Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0, + }; +} + +/** + * ノードごとのedge開始位置と、各ノードが何本のedgeから参照されているかを求める。 + * JS配列のelementsストアが専有されているか判定するために使う。 + */ +function indexEdges(view: HeapSnapshotView) { + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + + for (let nodeIndex = 0; nodeIndex < view.nodes.length; nodeIndex += view.nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = view.nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += view.edgeFieldCount) { + const toNodeIndex = view.edges[edgeIndex + view.edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + return { edgeStartIndexes, retainerCounts }; +} + +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const view = parseHeapSnapshot(snapshot); + const { nodes, edges, strings, nodeFieldCount, edgeFieldCount, nodeTypeNames, edgeTypeNames } = view; + + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + + const { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of heapSnapshotBreakdownCategories) { + breakdowns[category] = {}; + } + + const { edgeStartIndexes, retainerCounts } = indexEdges(view); + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: HeapSnapshotCategory, value: number, type: string, name: string, counted = true) { + if (value <= 0) return; + categories[category] += value; + const label = classifyHeapSnapshotBreakdown(category, type, name); + breakdowns[category][label] = (breakdowns[category][label] ?? 0) + value; + if (counted) nodeCounts[category]++; + } + + /** + * 配列オブジェクト自身のself_sizeには要素ストアが含まれないため、 + * その配列だけが参照しているelementsノードの分を JS arrays に加算する。 + */ + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + if (edges[currentEdgeIndex + view.edgeTypeOffset] !== internalEdgeType) continue; + if (strings[edges[currentEdgeIndex + view.edgeNameOffset]] !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + view.edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + addCategoryValue('jsArrays', nodes[elementsNodeIndex + view.selfSizeOffset] ?? 0, 'array elements', 'Array elements'); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (view.extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', view.extraNativeBytes, 'extra native bytes', 'extra native bytes', false); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + view.typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + view.selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name); + } else if (typeId === nativeType) { + addCategoryValue(name === 'system / JSArrayBufferData' ? 'typedArrays' : 'otherNonJsObjects', selfSize, type, name); + } else if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name); + } else if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name); + } else if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name); + addJsArrayElementSize(nodeIndex); + } + } + + categories.total += view.extraNativeBytes; + + // 上のループで JS arrays に計上したelementsノードが確定してから、残りを Other JS objects に振り分ける + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + view.typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + if (name === 'Array') continue; + + addCategoryValue('otherJsObjects', nodes[nodeIndex + view.selfSizeOffset] ?? 0, nodeTypeNames[typeId] ?? 'unknown', name); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts new file mode 100644 index 00000000000..0dd5ffe324f --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + defaultHeapSnapshotBreakdownTopN, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function sanitizeLabel(value: unknown, fallback = 'unknown') { + const label = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (label === '') return fallback; + if (label.length <= 80) return label; + return `${label.slice(0, 77)}...`; +} + +/** + * ノードの type / name から、内訳テーブルに出す粒度のラベルを決める。 + */ +export function classifyHeapSnapshotBreakdown(category: HeapSnapshotCategory, type: string, name: string) { + switch (category) { + case 'strings': + return type; + + case 'jsArrays': + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeLabel(`${type}: ${name}`); + + case 'typedArrays': + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeLabel(`${type}: ${name}`); + + case 'systemObjects': + if (name.startsWith('system /') || name.startsWith('(system ')) return sanitizeLabel(name); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'otherJsObjects': + if (type === 'object') return sanitizeLabel(`object: ${name}`, 'object: unknown'); + return type; + + case 'otherNonJsObjects': + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeLabel(`native: ${name}`, 'native: unknown'); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'code': { + const lowerName = name.toLowerCase(); + if (lowerName.includes('bytecode')) return 'bytecode'; + if (lowerName.includes('builtin')) return 'builtins'; + if (lowerName.includes('regexp')) return 'regexp code'; + if (lowerName.includes('stub')) return 'stubs'; + return sanitizeLabel(`code: ${name}`, 'code: unknown'); + } + + default: + return sanitizeLabel(`${type}: ${name}`, type); + } +} + +/** + * 内訳を上位N件に丸め、残りを `Other` にまとめる。 + */ +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const collapsed = Object.fromEntries(entries.slice(0, topN)); + const otherValue = entries.slice(topN).reduce((sum, [, value]) => sum + value, 0); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts new file mode 100644 index 00000000000..4e7caed6d85 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// Chrome DevTools の heap snapshot Statistics ビューと同じ分類になるように保つこと。 +export const heapSnapshotCategory = { + total: { label: 'Total', color: 'gray', colorHex: '#888888' }, + code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' }, + strings: { label: 'Strings', color: 'red', colorHex: '#e15759' }, + jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' }, + typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' }, + systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' }, + otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' }, + otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' }, +} as const satisfies Record; + +export type HeapSnapshotCategory = keyof typeof heapSnapshotCategory; + +export const heapSnapshotCategories = Object.keys(heapSnapshotCategory) as HeapSnapshotCategory[]; + +/** `total` は他カテゴリの合算ではなく全体量なので、内訳を扱うときは除外する */ +export const heapSnapshotBreakdownCategories = heapSnapshotCategories.filter(category => category !== 'total'); + +export type HeapSnapshotData = { + categories: Record; + nodeCounts: Record; + /** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */ + breakdowns?: Partial>>; +}; + +export type HeapSnapshotReport = { + summary: HeapSnapshotData; + samples: { + round: number; + data: HeapSnapshotData; + }[]; +}; + +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): HeapSnapshotData { + const categories = {} as HeapSnapshotData['categories']; + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of heapSnapshotCategories) { + categories[category] = 0; + nodeCounts[category] = 0; + } + return { + categories, + nodeCounts, + breakdowns: {}, + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/index.ts b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts new file mode 100644 index 00000000000..0634efac878 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * from './analyze'; +export * from './breakdown'; +export * from './categories'; +export * from './render'; +export * from './summarize'; diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/render.ts b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts new file mode 100644 index 00000000000..9d6034fb388 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatBytes } from '../format'; +import { renderMetricComparisonTable } from '../metric-table'; +import { + heapSnapshotCategories, + heapSnapshotCategory, + type HeapSnapshotCategory, + type HeapSnapshotReport, +} from './categories'; + +/** これ未満のバイト差分には色を付けない (0.1 MB) */ +const byteColorThreshold = 100_000; + +function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + return report.summary.categories[category]; +} + +function swatch(category: HeapSnapshotCategory) { + // eslint-disable-next-line no-irregular-whitespace + return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$\u00A0**${heapSnapshotCategory[category].label.replaceAll(' ', '\u00A0')}**`; // nbspにすること +} + +/** + * base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。 + */ +export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { + return renderMetricComparisonTable( + base.samples, + head.samples, + heapSnapshotCategories.map(category => ({ + label: swatch(category), + getValue: sample => sample.data.categories[category], + formatValue: formatBytes, + absoluteThreshold: byteColorThreshold, + showMedianMad: category === 'total', + showDeltaPercentage: category === 'total', + separatorAfter: category === 'total', + })), + ); +} + +const sankeyChildMinRatio = 0.3; +const sankeyParentMinPercent = 10; + +function escapeCsvValue(value: string) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +function formatSankeyPercentValue(value: number) { + const rounded = Math.round(value * 100) / 100; + if (rounded === 0 && value > 0) return '0.01'; + if (Number.isInteger(rounded)) return String(rounded); + return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +/** + * heap snapshotの構成比をmermaidのsankey図として描画する。 + * 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。 + */ +export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) { + const total = categoryValue(report, 'total'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (total == null || total <= 0) return null; + + const categories = heapSnapshotCategories + .filter(category => category !== 'total') + .map(category => { + const value = categoryValue(report, category); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value == null || value <= 0) return null; + + const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {}) + .filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0) + .toSorted((a, b) => b[1] - a[1]); + const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); + const percent = (value * 100) / total; + const childEntries: [string, number][] = []; + let otherPercent = 0; + + if (breakdownTotal > 0 && percent > sankeyParentMinPercent) { + for (const [childName, childValue] of breakdownEntries) { + const childRatio = childValue / breakdownTotal; + if (childRatio >= sankeyChildMinRatio) { + childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]); + } else { + otherPercent += percent * childRatio; + } + } + + if (childEntries.length > 0 && otherPercent > 0) { + childEntries.push(['Other', otherPercent]); + } + } + + return { category, percent, childEntries }; + }) + .filter(value => value != null); + + if (categories.length === 0) return null; + + const nodeColors: Record = { + [title]: heapSnapshotCategory.total.colorHex, + Other: '#888888', + }; + for (const { category, childEntries } of categories) { + nodeColors[category] = heapSnapshotCategory[category].colorHex; + for (const [childName] of childEntries) { + nodeColors[childName] = heapSnapshotCategory[category].colorHex; + } + } + + const lines = [ + `
${title} heap snapshot composition`, + '', + '```mermaid', + `%%{init: ${JSON.stringify({ + sankey: { + showValues: false, + linkColor: 'target', + labelStyle: 'outlined', + nodeAlignment: 'center', + nodePadding: 10, + nodeColors, + }, + })}}%%`, + 'sankey-beta', + ]; + + for (const { category, percent, childEntries } of categories) { + const categoryLabel = heapSnapshotCategory[category].label; + lines.push(`${escapeCsvValue(title)},${escapeCsvValue(categoryLabel)},${formatSankeyPercentValue(percent)}`); + + for (const [childName, childPercent] of childEntries) { + lines.push(`${escapeCsvValue(categoryLabel)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`); + } + } + + lines.push('```', '', '
'); + + return lines.join('\n'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts new file mode 100644 index 00000000000..bbc2a6d203a --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { finiteMedian } from '../stats'; +import { collapseHeapSnapshotBreakdown } from './breakdown'; +import { + heapSnapshotBreakdownCategories, + heapSnapshotCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function isComplete(values: Partial>): values is Record { + return heapSnapshotCategories.every(category => values[category] != null); +} + +/** + * 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。 + * 全カテゴリ分の値が揃わなければ null を返す。 + */ +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + + const categories: Partial = {}; + const nodeCounts: Partial = {}; + for (const category of heapSnapshotCategories) { + const categoryValue = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (categoryValue != null) categories[category] = categoryValue; + + const nodeCountValue = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (nodeCountValue != null) nodeCounts[category] = nodeCountValue; + } + + // 一部のカテゴリだけ欠けた状態で返すと、呼び出し側が完全な値として扱って + // undefined を描画してしまう。全カテゴリ揃っていなければサマリ自体を無しとする + if (!isComplete(categories) || !isComplete(nodeCounts)) return null; + + const breakdowns: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {}))); + + const categoryBreakdown = {} as Record; + for (const childKey of childKeys) { + const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); + if (value != null) categoryBreakdown[childKey] = value; + } + + const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); + if (Object.keys(collapsed).length > 0) breakdowns[category] = collapsed; + } + + return { + categories, + nodeCounts, + ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), + } satisfies HeapSnapshotData; +} diff --git a/packages-private/diagnostics-shared/src/html.ts b/packages-private/diagnostics-shared/src/html.ts new file mode 100644 index 00000000000..907c089656e --- /dev/null +++ b/packages-private/diagnostics-shared/src/html.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { escapeHtml } from './format'; + +/** + * エスケープ済み、あるいは意図的にエスケープしない生のHTML断片。 + * + * ただの文字列と型で区別するためだけの存在ではなく、`html` が実行時に + * 「この値はもうエスケープしなくてよい」と判定するためのマーカーでもある。 + * 型だけのブランドにすると実行時に判定できず、結局エスケープ漏れを防げない。 + */ +export class Raw { + constructor(private readonly value: string) {} + + toString() { + return this.value; + } +} + +/** + * 文字列をエスケープせずそのまま埋め込む。 + * 呼び出しが差分に残るので、レビューで「なぜ生で入れてよいのか」を確認できる。 + */ +export function raw(value: string) { + return new Raw(value); +} + +function interpolate(value: unknown): string { + if (value instanceof Raw) return value.toString(); + // 配列をそのまま文字列化するとカンマ区切りで潰れるので、要素ごとに処理する + if (Array.isArray(value)) return value.map(interpolate).join(''); + if (value == null) return ''; + return escapeHtml(value); +} + +/** + * HTMLを組み立てるタグ付きテンプレート。補間値は既定でエスケープされる。 + * エスケープしたくない場合は `raw()` で包む必要があるため、 + * 「うっかり生のまま埋め込む」ことが起きない。 + */ +export function html(strings: TemplateStringsArray, ...values: unknown[]) { + let result = strings[0]; + for (let i = 0; i < values.length; i++) { + result += interpolate(values[i]) + strings[i + 1]; + } + return new Raw(result); +} + +/** + * 断片を指定の区切り文字で連結する。 + * `html` の配列補間は区切り無しで繋ぐので、改行などを挟みたいときはこちらを使う。 + */ +export function joinHtml(parts: readonly Raw[], separator: string) { + return new Raw(parts.map(part => part.toString()).join(separator)); +} diff --git a/packages-private/diagnostics-shared/src/metric-table.ts b/packages-private/diagnostics-shared/src/metric-table.ts new file mode 100644 index 00000000000..abd897116f2 --- /dev/null +++ b/packages-private/diagnostics-shared/src/metric-table.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatColoredDelta, formatDeltaPercentInMdTable } from './format'; +import { + independentDeltaSummary, + isOutsideObservedNoise, + type IndependentDeltaSummary, +} from './stats'; + +export type MetricComparisonRow = { + label: string; + getValue: (sample: T) => number; + formatValue: (value: number) => string; + absoluteThreshold: number; + showMedianMad?: boolean; + showDeltaPercentage?: boolean; + separatorAfter?: boolean; +}; + +export type MetricComparisonTableOptions = { + onlySignificantChanges?: boolean; +}; + +function isSignificant(summary: IndependentDeltaSummary, absoluteThreshold: number) { + return isOutsideObservedNoise(summary) && Math.abs(summary.delta) >= absoluteThreshold; +} + +function formatMedian( + value: number, + spread: number, + row: MetricComparisonRow, +) { + const formatted = row.formatValue(value); + if (row.showMedianMad === false) return formatted; + // eslint-disable-next-line no-irregular-whitespace + return `${formatted}
±\u00A0${row.formatValue(spread)}`; // nbspにすること +} + +function formatDelta( + summary: IndependentDeltaSummary, + row: MetricComparisonRow, + significant: boolean, +) { + const colorThreshold = significant ? 0 : Number.POSITIVE_INFINITY; + const absolute = formatColoredDelta(summary.delta, row.formatValue, colorThreshold); + if (row.showDeltaPercentage === false) return absolute; + + const percentage = summary.baseMedian === 0 + ? '-' + : formatDeltaPercentInMdTable(summary.delta * 100 / summary.baseMedian, colorThreshold); + return `${absolute}
${percentage}`; +} + +export function renderMetricComparisonTable( + baseSamples: T[], + headSamples: T[], + rows: MetricComparisonRow[], + options: MetricComparisonTableOptions = {}, +): string { + const lines: string[] = []; + let omitted = false; + + for (const row of rows) { + const summary = independentDeltaSummary(baseSamples, headSamples, row.getValue); + const significant = isSignificant(summary, row.absoluteThreshold); + if (options.onlySignificantChanges === true && !significant) { + omitted = true; + continue; + } + + lines.push(`| ${row.label} | ${formatMedian(summary.baseMedian, summary.baseMad, row)} | ${formatMedian(summary.headMedian, summary.headMad, row)} | ${formatDelta(summary, row, significant)} | ${row.formatValue(summary.combinedMad)} |`); + if (row.separatorAfter === true) lines.push('| | | | | |'); + } + + if (lines.length === 0) return '**(No significant changes)**'; + + return [ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + ...lines, + ...(omitted ? ['', 'Only metrics showing significant changes are displayed.'] : []), + ].join('\n'); +} diff --git a/packages-private/diagnostics-shared/src/stats.ts b/packages-private/diagnostics-shared/src/stats.ts new file mode 100644 index 00000000000..20fd7b13472 --- /dev/null +++ b/packages-private/diagnostics-shared/src/stats.ts @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function median(values: number[]) { + const sorted = values.toSorted((a, b) => a - b); + const center = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[center]; + return Math.round((sorted[center - 1] + sorted[center]) / 2); +} + +export function mad(values: number[]) { + if (values.length < 2) throw new Error('Not enough samples to calculate MAD'); + + const center = median(values); + return median(values.map(value => Math.abs(value - center))); +} + +/** + * 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。 + */ +export function finiteMedian(values: (number | null | undefined)[]): number | null; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return median(finiteValues); +} + +/** + * サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。 + */ +export function sampleSpread(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length < 2) return null; + return mad(finiteValues); +} + +export type IndependentDeltaSummary = { + baseMedian: number; + headMedian: number; + delta: number; + baseMad: number; + headMad: number; + combinedMad: number; + baseSamples: number; + headSamples: number; +}; + +export function independentDeltaSummary( + baseSamples: T[], + headSamples: T[], + getValue: (sample: T) => number, +): IndependentDeltaSummary { + const baseValues = baseSamples.map(getValue); + const headValues = headSamples.map(getValue); + if (baseValues.length < 2 || headValues.length < 2) { + throw new Error('At least two samples per side are required'); + } + + const baseMedian = median(baseValues); + const headMedian = median(headValues); + const baseMad = mad(baseValues); + const headMad = mad(headValues); + + return { + baseMedian, + headMedian, + delta: headMedian - baseMedian, + baseMad, + headMad, + combinedMad: Math.hypot(baseMad, headMad), + baseSamples: baseValues.length, + headSamples: headValues.length, + }; +} + +export function isOutsideObservedNoise(summary: IndependentDeltaSummary) { + return Math.abs(summary.delta) > summary.combinedMad * 3; +} + +type RoundedSample = { round: number }; + +function indexByRound(samples: T[]) { + const samplesByRound = new Map(); + for (const sample of samples) { + // 負のroundはwarmupを表すため対象外 + if (sample.round <= 0) continue; + samplesByRound.set(sample.round, sample); + } + return samplesByRound; +} + +/** + * base / head を同じroundどうしで突き合わせ、その差分の分布を要約する。 + * 実行順による揺らぎの影響を抑えるため、単純な集計値どうしの引き算ではなくペア差分を使う。 + */ +export function pairedDeltaSummary(baseSamples: T[], headSamples: T[], getValue: (sample: T) => number | null | undefined) { + const baseSamplesByRound = indexByRound(baseSamples); + const headSamplesByRound = indexByRound(headSamples); + const values: number[] = []; + + for (const [round, baseSample] of baseSamplesByRound) { + const headSample = headSamplesByRound.get(round); + if (headSample == null) continue; + + const baseValue = getValue(baseSample); + const headValue = getValue(headSample); + if (baseValue == null || headValue == null) continue; + + values.push(headValue - baseValue); + } + + // 対応するroundが1つも無いと中央値も最小/最大も定義できない。 + // 静かにNaNやInfinityをレポートに載せるより、比較が成立していないと分かる形で落とす + if (values.length === 0) throw new Error('No paired samples to compare: base and head have no rounds in common'); + + return { + median: median(values), + // 1サンプルでは中央値からの偏差が常に0になる (mad() は統計として無意味なので拒否する) + mad: values.length < 2 ? 0 : mad(values), + min: Math.min(...values), + max: Math.max(...values), + samples: values.length, + }; +} diff --git a/packages-private/diagnostics-shared/test/format.test.ts b/packages-private/diagnostics-shared/test/format.test.ts new file mode 100644 index 00000000000..1c69d10387c --- /dev/null +++ b/packages-private/diagnostics-shared/test/format.test.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + calcAndFormatDeltaPercent, + calcAndFormatDeltaPercentInMdTable, + escapeHtml, + escapeLatex, + escapeMdTableCell, + formatBytes, + formatColoredDelta, + formatKiBAsMb, + formatMs, + formatNumber, +} from '../src/format'; + +describe('formatBytes', () => { + // 1024ではなく1000区切り。単位が2桁以上なら小数を落とす + test.each([ + [0, '0\u00A0B'], + [999, '999\u00A0B'], + [1_000, '1\u00A0KB'], + [1_500, '1.5\u00A0KB'], + [15_000, '15\u00A0KB'], + [1_234_567, '1.2\u00A0MB'], + [12_345_678, '12\u00A0MB'], + [1_500_000_000, '1.5\u00A0GB'], + [1_500_000_000_000, '1,500\u00A0GB'], + ])('formats %i as %s', (input, expected) => { + expect(formatBytes(input)).toBe(expected); + }); +}); + +describe('formatColoredDelta', () => { + test('leaves zero uncoloured and unsigned', () => { + expect(formatColoredDelta(0, formatNumber)).toBe('0'); + }); + + test('colours growth orange and shrinkage green', () => { + expect(formatColoredDelta(5, formatNumber)).toBe('$\\color{orange}{\\text{+5}}$'); + expect(formatColoredDelta(-5, formatNumber)).toBe('$\\color{green}{\\text{-5}}$'); + }); + + test('omits colour below the threshold but keeps the sign', () => { + expect(formatColoredDelta(5, formatNumber, 10)).toBe('$\\text{+5}$'); + }); +}); + +describe('escaping', () => { + test('escapeHtml covers the five metacharacters', () => { + expect(escapeHtml(`&<>"'`)).toBe('&<>"''); + }); + + test('escapeHtml maps nullish to an empty string', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); + + test('escapeLatex escapes braces and percent', () => { + expect(escapeLatex('100% {x}')).toBe('100\\% \\{x\\}'); + }); + + test('escapeMdTableCell keeps pipes and newlines from breaking the table', () => { + expect(escapeMdTableCell('a|b\nc')).toBe('a\\|b
c'); + }); +}); + +describe('percent helpers', () => { + // before が0だと変化率そのものが定義できない + test('returns a placeholder when the baseline is zero or missing', () => { + expect(calcAndFormatDeltaPercent(0, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(null, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(10, null)).toBe('-'); + }); + + // 0になったのは「消えた」という有効な結果なので、隠さず -100% として出す + test('formats a drop to zero as -100%', () => { + expect(calcAndFormatDeltaPercent(10, 0)).toBe('$\\color{green}{\\text{-100\\%}}$'); + }); + + // Markdownのテーブルセル内ではLaTeXの \% がさらに食われるため二重にする + test('doubles the percent escape for markdown table cells', () => { + expect(calcAndFormatDeltaPercentInMdTable(100, 110)).toContain('\\\\%'); + expect(calcAndFormatDeltaPercent(100, 110)).not.toContain('\\\\%'); + }); +}); + +describe('nullish handling', () => { + test('formatKiBAsMb and formatMs render a dash for missing values', () => { + expect(formatKiBAsMb(null)).toBe('-'); + expect(formatKiBAsMb(Number.NaN)).toBe('-'); + expect(formatMs(undefined)).toBe('-'); + }); + + test('formatMs switches to seconds past 1000ms', () => { + expect(formatMs(999)).toBe('999 ms'); + expect(formatMs(1_500)).toBe('1.5 s'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts b/packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts new file mode 100644 index 00000000000..68e1c112cd7 --- /dev/null +++ b/packages-private/diagnostics-shared/test/heap-snapshot-render.test.ts @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + createEmptyHeapSnapshotData, + renderHeapSnapshotTable, + summarizeHeapSnapshotDataSamples, + type HeapSnapshotData, + type HeapSnapshotReport, +} from '../src/heap-snapshot'; + +function snapshot(total: number): HeapSnapshotData { + const data = createEmptyHeapSnapshotData(); + data.categories.total = total; + return data; +} + +function report(totals: number[]): HeapSnapshotReport { + const samples = totals.map((total, index) => ({ + round: index + 1, + data: snapshot(total), + })); + const summary = summarizeHeapSnapshotDataSamples(samples, sample => sample.data); + if (summary == null) throw new Error('expected heap snapshot samples to produce a summary'); + + return { + summary, + samples, + }; +} + +function totalRow(markdown: string) { + const row = markdown.split('\n').find(line => line.includes('**Total**')); + if (row === undefined) throw new Error('expected heap snapshot table to contain a Total row'); + return row; +} + +describe('renderHeapSnapshotTable', () => { + test('leaves a threshold-sized delta uncoloured when it is within observed noise', () => { + const row = totalRow(renderHeapSnapshotTable( + report([1_000_000, 1_100_000, 1_200_000]), + report([1_100_000, 1_200_000, 1_300_000]), + )); + + expect(row).toContain('$\\text{+100\u00A0KB}$'); + expect(row).not.toContain('within noise'); + expect(row).not.toContain('\\color{orange}'); + }); + + test('colours both delta lines for a clear increase at or above the absolute threshold', () => { + const row = totalRow(renderHeapSnapshotTable( + report([1_000_000, 1_000_000, 1_000_000]), + report([1_200_000, 1_200_000, 1_200_000]), + )); + + expect(row).toContain('$\\color{orange}{\\text{+200\u00A0KB}}$'); + expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$'); + expect(row).not.toContain('increase'); + }); + + test('leaves both delta lines uncoloured below the absolute threshold', () => { + const row = totalRow(renderHeapSnapshotTable( + report([1_000_000, 1_000_000, 1_000_000]), + report([1_050_000, 1_050_000, 1_050_000]), + )); + + expect(row).toContain('$\\text{+50\u00A0KB}$
$\\text{+5\\\\%}$'); + expect(row.split('|')[4]).not.toContain('\\color{'); + }); + + test('throws when only one snapshot per side reaches the renderer', () => { + expect(() => renderHeapSnapshotTable( + report([1_000_000]), + report([1_200_000]), + )).toThrow('At least two samples per side are required'); + }); + + test('uses two-line formatting only for Total and puts a five-column separator after it', () => { + const table = renderHeapSnapshotTable( + report([1_000_000, 1_000_000, 1_000_000]), + report([1_200_000, 1_200_000, 1_200_000]), + ); + const lines = table.split('\n'); + const totalIndex = lines.findIndex(line => line.includes('**Total**')); + const categoryRow = lines.find(line => line.includes('**Code**')); + + expect(lines.slice(0, 2)).toStrictEqual([ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + ]); + expect(table).not.toContain('Result'); + expect(totalIndex).toBeGreaterThan(1); + expect(lines[totalIndex].match(/
/g)).toHaveLength(3); + expect(lines[totalIndex + 1]).toBe('| | | | | |'); + expect(categoryRow).toBeDefined(); + expect(categoryRow).not.toContain('
'); + expect(categoryRow).not.toContain('
'); + expect(categoryRow).not.toContain('→'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/html.test.ts b/packages-private/diagnostics-shared/test/html.test.ts new file mode 100644 index 00000000000..6e9dbc48b0b --- /dev/null +++ b/packages-private/diagnostics-shared/test/html.test.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { html, joinHtml, raw, Raw } from '../src/html'; + +describe('html', () => { + test('escapes interpolated values by default', () => { + const value = ''; + expect(String(html`

${value}

`)).toBe('

<script>alert(1)</script>

'); + }); + + test('escapes values used in attribute position', () => { + const url = 'x">'; + expect(String(html``)).toBe(''); + }); + + test('leaves the static parts of the template untouched', () => { + expect(String(html`

x

`)).toBe('

x

'); + }); + + test('renders nullish values as an empty string', () => { + expect(String(html`

${null}${undefined}

`)).toBe('

'); + }); + + test('does not double-escape nested fragments', () => { + const inner = html`${'a&b'}`; + expect(String(html`

${inner}

`)).toBe('

a&b

'); + }); + + // 配列をそのまま文字列化するとカンマ区切りで潰れる。実際に過去これで表が壊れた + test('joins arrays without separators instead of stringifying them', () => { + const items = [html`
  • 1
  • `, html`
  • 2
  • `]; + expect(String(html`
      ${items}
    `)).toBe('
    • 1
    • 2
    '); + }); + + test('escapes array elements that are not fragments', () => { + expect(String(html`

    ${['', '']}

    `)).toBe('

    <a><b>

    '); + }); +}); + +describe('raw', () => { + test('embeds trusted markup without escaping', () => { + expect(String(html``)).toBe(''); + }); + + test('produces a Raw fragment', () => { + expect(raw('x')).toBeInstanceOf(Raw); + expect(html`x`).toBeInstanceOf(Raw); + }); +}); + +describe('joinHtml', () => { + test('joins fragments with the given separator', () => { + expect(String(joinHtml([html`
  • 1
  • `, html`
  • 2
  • `], '\n'))).toBe('
  • 1
  • \n
  • 2
  • '); + }); + + test('returns an empty fragment for an empty list', () => { + expect(String(joinHtml([], '\n'))).toBe(''); + }); +}); diff --git a/packages-private/diagnostics-shared/test/metric-table.test.ts b/packages-private/diagnostics-shared/test/metric-table.test.ts new file mode 100644 index 00000000000..c9fd69ae097 --- /dev/null +++ b/packages-private/diagnostics-shared/test/metric-table.test.ts @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + renderMetricComparisonTable, + type MetricComparisonRow, +} from '../src/metric-table'; + +type Sample = { value: number }; + +const defaultRow: MetricComparisonRow = { + label: 'Metric', + getValue: sample => sample.value, + formatValue: value => `${value} units`, + absoluteThreshold: 10, +}; + +function samples(...values: number[]): Sample[] { + return values.map(value => ({ value })); +} + +describe('renderMetricComparisonTable', () => { + test('renders the fixed five-column layout, raw label, MAD, and percentage by default', () => { + const table = renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 120, 120), + [defaultRow], + ); + + expect(table.split('\n')).toStrictEqual([ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + '| Metric | 100 units
    ±\u00A00 units | 120 units
    ±\u00A00 units | $\\color{orange}{\\text{+20~units}}$
    $\\color{orange}{\\text{+20\\\\%}}$ | 0 units |', + ]); + }); + + test('can hide second lines and insert a five-column separator row', () => { + const table = renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 120, 120), + [{ + ...defaultRow, + showMedianMad: false, + showDeltaPercentage: false, + separatorAfter: true, + }], + ); + + expect(table.split('\n')).toStrictEqual([ + '| Metric | @ Base | @ Head | Δ | MAD |', + '| --- | ---: | ---: | ---: | ---: |', + '| Metric | 100 units | 120 units | $\\color{orange}{\\text{+20~units}}$ | 0 units |', + '| | | | | |', + ]); + }); + + test('leaves both delta lines uncolored below the absolute threshold and can filter the row', () => { + const base = samples(100, 100, 100); + const head = samples(109, 109, 109); + const table = renderMetricComparisonTable(base, head, [defaultRow]); + + expect(table).toContain('$\\text{+9~units}$
    $\\text{+9\\\\%}$'); + expect(table).not.toContain('\\color{'); + expect(renderMetricComparisonTable(base, head, [defaultRow], { + onlySignificantChanges: true, + })).toBe('**(No significant changes)**'); + }); + + test('renders a no-data marker when no rows are configured', () => { + expect(renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 120, 120), + [], + )).toBe('**(No significant changes)**'); + }); + + test('treats the absolute threshold itself as significant', () => { + const table = renderMetricComparisonTable( + samples(100, 100, 100), + samples(110, 110, 110), + [defaultRow], + { onlySignificantChanges: true }, + ); + + expect(table).toContain('$\\color{orange}{\\text{+10~units}}$'); + expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$'); + }); + + test('requires the delta to be strictly outside three combined MADs', () => { + const inside = renderMetricComparisonTable( + samples(100, 110, 120), + samples(109, 119, 129), + [{ ...defaultRow, absoluteThreshold: 1 }], + ); + const boundary = renderMetricComparisonTable( + samples(100, 100, 100), + samples(120, 130, 140), + [defaultRow], + ); + const outside = renderMetricComparisonTable( + samples(100, 100, 100), + samples(121, 131, 141), + [defaultRow], + ); + + expect(inside).toContain('$\\text{+9~units}$'); + expect(inside).not.toContain('\\color{'); + 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\\\\%}}$'); + }); + + test('colours a significant decrease green on both delta lines', () => { + const table = renderMetricComparisonTable( + samples(120, 120, 120), + samples(100, 100, 100), + [defaultRow], + ); + + expect(table).toContain('$\\color{green}{\\text{-20~units}}$'); + expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$'); + }); + + test('shows a dash for percentage when the base median is zero', () => { + const table = renderMetricComparisonTable( + samples(0, 0, 0), + samples(20, 20, 20), + [defaultRow], + ); + + expect(table).toContain('$\\color{orange}{\\text{+20~units}}$
    -'); + }); + + test('propagates the minimum sample contract', () => { + expect(() => renderMetricComparisonTable( + samples(100), + samples(120, 120), + [defaultRow], + )).toThrow('At least two samples per side are required'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/stats.test.ts b/packages-private/diagnostics-shared/test/stats.test.ts new file mode 100644 index 00000000000..22da6a65317 --- /dev/null +++ b/packages-private/diagnostics-shared/test/stats.test.ts @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + finiteMedian, + independentDeltaSummary, + isOutsideObservedNoise, + mad, + median, + pairedDeltaSummary, + sampleSpread, + type IndependentDeltaSummary, +} from '../src/stats'; + +describe('median', () => { + test('takes the middle value of an odd-length sample', () => { + expect(median([3, 1, 2])).toBe(2); + }); + + // 偶数長では平均を整数に丸める。KiB単位の整数値を扱う前提のため + test('rounds the average of an even-length sample', () => { + expect(median([1, 2])).toBe(2); + expect(median([1, 4])).toBe(3); + }); +}); + +describe('mad', () => { + test('measures the median absolute deviation', () => { + expect(mad([1, 1, 1])).toBe(0); + expect(mad([1, 2, 3])).toBe(1); + }); + + test('refuses to compute from a single sample', () => { + expect(() => mad([1])).toThrow(); + }); +}); + +describe('finiteMedian', () => { + test('ignores non-finite entries', () => { + expect(finiteMedian([1, null, undefined, Number.NaN, 3])).toBe(2); + }); + + test('returns null by default when nothing is finite', () => { + expect(finiteMedian([null, undefined])).toBeNull(); + }); + + test('returns the supplied default when nothing is finite', () => { + expect(finiteMedian([null, undefined], 0)).toBe(0); + }); +}); + +describe('sampleSpread', () => { + test('needs at least two finite samples', () => { + expect(sampleSpread([1])).toBeNull(); + expect(sampleSpread([1, null])).toBeNull(); + expect(sampleSpread([1, 3])).toBe(1); + }); +}); + +describe('pairedDeltaSummary', () => { + const base = [ + { round: 1, value: 100 }, + { round: 2, value: 200 }, + { round: 3, value: 300 }, + ]; + + test('compares base and head within the same round', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 3, value: 320 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value)).toStrictEqual({ + median: 20, + mad: 10, + min: 10, + max: 30, + samples: 3, + }); + }); + + test('drops rounds that only one side has', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 9, value: 999 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value).samples).toBe(2); + }); + + // 1サンプルでも中央値・最小・最大は定まる (偏差は常に0) + test('summarizes a single paired round without treating MAD as an error', () => { + expect(pairedDeltaSummary([base[0]], [{ round: 1, value: 130 }], sample => sample.value)).toStrictEqual({ + median: 30, + mad: 0, + min: 30, + max: 30, + samples: 1, + }); + }); + + test('fails loudly when no round is shared', () => { + expect(() => pairedDeltaSummary(base, [{ round: 9, value: 1 }], sample => sample.value)).toThrow(/no rounds in common/); + }); + + // 負のroundはwarmupを表すので集計に混ぜない + test('ignores warmup rounds', () => { + const warmupBase = [{ round: -1, value: 0 }, ...base]; + const warmupHead = [{ round: -1, value: 9999 }, { round: 1, value: 110 }, { round: 2, value: 230 }, { round: 3, value: 320 }]; + + expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3); + }); +}); + +describe('independentDeltaSummary', () => { + const base = [290_000, 292_900, 295_800, 298_700, 301_600] + .map((value, index) => ({ round: index + 1, value })); + const head = [292_900, 296_300, 298_700, 301_600, 290_000] + .map((value, index) => ({ round: index + 1, value })); + + test('uses the difference of independent medians instead of the paired median', () => { + expect(pairedDeltaSummary(base, head, sample => sample.value).median).toBe(2_900); + + const summary = independentDeltaSummary(base, head, sample => sample.value); + expect(summary).toMatchObject({ + baseMedian: 295_800, + headMedian: 296_300, + delta: 500, + baseMad: 2_900, + headMad: 3_400, + baseSamples: 5, + headSamples: 5, + }); + expect(summary.combinedMad).toBeCloseTo(Math.hypot(2_900, 3_400)); + }); + + test('allows unequal sample counts', () => { + const summary = independentDeltaSummary( + [{ value: 10 }, { value: 20 }], + [{ value: 20 }, { value: 30 }, { value: 40 }], + sample => sample.value, + ); + + expect(summary).toStrictEqual({ + baseMedian: 15, + headMedian: 30, + delta: 15, + baseMad: 5, + headMad: 10, + combinedMad: Math.hypot(5, 10), + baseSamples: 2, + headSamples: 3, + }); + }); + + test('throws when either side has fewer than two samples', () => { + expect(() => independentDeltaSummary( + [{ value: 10 }], + [{ value: 20 }, { value: 20 }], + sample => sample.value, + )).toThrow('At least two samples per side are required'); + + expect(() => independentDeltaSummary( + [{ value: 10 }, { value: 10 }], + [{ value: 20 }], + sample => sample.value, + )).toThrow('At least two samples per side are required'); + }); + + test('treats exactly three combined MADs as noise and only larger deltas as outside noise', () => { + function summary(delta: number, combinedMad: number): IndependentDeltaSummary { + return { + baseMedian: 100, + headMedian: 100 + delta, + delta, + baseMad: 0, + headMad: combinedMad, + combinedMad, + baseSamples: 3, + headSamples: 3, + }; + } + + expect(isOutsideObservedNoise(summary(29, 10))).toBe(false); + expect(isOutsideObservedNoise(summary(30, 10))).toBe(false); + expect(isOutsideObservedNoise(summary(31, 10))).toBe(true); + expect(isOutsideObservedNoise(summary(-31, 10))).toBe(true); + }); +}); diff --git a/packages-private/diagnostics-shared/tsconfig.json b/packages-private/diagnostics-shared/tsconfig.json new file mode 100644 index 00000000000..6672c40acb3 --- /dev/null +++ b/packages-private/diagnostics-shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages/backend/migration/1784899839024-HashtagTableDefaults.js b/packages/backend/migration/1784899839024-HashtagTableDefaults.js new file mode 100644 index 00000000000..4b890fa8493 --- /dev/null +++ b/packages/backend/migration/1784899839024-HashtagTableDefaults.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class HashtagTableDefaults1784899839024 { + name = 'HashtagTableDefaults1784899839024' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index b8729614bf8..ebfe45c48a4 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,11 +19,11 @@ "build": "rolldown -c", "build:unit": "rolldown -c --sourcemap", "build:e2e": "rolldown -c --e2e", - "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json", + "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "pnpm compile-config && node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", "dev": "pnpm compile-config && rolldown -c --watch", - "typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit", + "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "test": "pnpm build:unit && cross-env NODE_ENV=test pnpm compile-config && vitest --config vitest.config.unit.ts", @@ -52,105 +52,105 @@ "utf-8-validate": "6.0.6" }, "dependencies": { - "@aws-sdk/client-s3": "3.1075.0", - "@aws-sdk/lib-storage": "3.1075.0", + "@aws-sdk/client-s3": "3.1107.0", + "@aws-sdk/lib-storage": "3.1107.0", "@fastify/accepts": "5.0.4", - "@fastify/cors": "11.2.0", - "@fastify/http-proxy": "11.5.0", - "@fastify/multipart": "10.0.0", - "@fastify/static": "9.1.3", + "@fastify/cors": "11.3.0", + "@fastify/http-proxy": "11.6.0", + "@fastify/multipart": "10.1.0", + "@fastify/static": "10.1.3", "@kitajs/html": "4.2.13", "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/emoji-data": "17.0.3", "@misskey-dev/sharp-read-bmp": "1.3.1", "@misskey-dev/summaly": "5.5.1", - "@napi-rs/canvas": "1.0.1", - "@nestjs/common": "11.1.27", - "@nestjs/core": "11.1.27", - "@nestjs/testing": "11.1.27", - "@oxc-project/runtime": "0.137.0", + "@napi-rs/canvas": "1.0.5", + "@nestjs/common": "11.1.29", + "@nestjs/core": "11.1.29", + "@nestjs/testing": "11.1.29", + "@oxc-project/runtime": "0.144.0", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.62.0", - "@sentry/profiling-node": "10.62.0", + "@sentry/node": "10.70.0", + "@sentry/profiling-node": "10.70.0", "@simplewebauthn/server": "13.3.2", - "@smithy/node-http-handler": "4.8.2", + "@smithy/node-http-handler": "4.9.13", "accepts": "1.3.8", "ajv": "8.20.0", "archiver": "8.0.0", "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "bullmq": "5.79.2", + "bullmq": "6.0.11", "cacheable-lookup": "7.0.0", - "chalk": "5.6.2", + "chalk": "6.0.0", "chalk-template": "1.1.2", "chokidar": "5.0.0", "color-convert": "3.1.3", "content-disposition": "2.0.1", "date-fns": "4.4.0", "deep-email-validator": "0.1.27", - "fastify": "5.9.0", - "fastify-raw-body": "5.0.0", - "feed": "5.2.1", + "fastify": "5.11.3", + "fastify-raw-body": "6.0.1", + "feed": "6.0.0", "file-type": "22.0.1", "fluent-ffmpeg": "2.1.3", - "got": "15.0.7", + "got": "15.1.0", "hpagent": "1.2.0", - "http-link-header": "1.1.3", + "http-link-header": "1.1.4", "i18n": "workspace:*", "ioredis": "5.11.1", "ip-cidr": "4.0.2", - "ipaddr.js": "2.4.0", + "ipaddr.js": "2.5.0", "is-svg": "6.1.0", "json5": "2.2.3", "jsonld": "9.0.0", - "juice": "12.1.1", - "meilisearch": "0.58.0", + "juice": "12.1.2", + "meilisearch": "0.60.0", "mfm-js": "0.26.0", "mime-types": "3.0.2", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.202508261828", - "nanoid": "5.1.16", + "nanoid": "6.0.1", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "node-html-parser": "8.0.3", - "nodemailer": "9.0.1", + "node-html-parser": "9.0.1", + "nodemailer": "9.0.5", "os-utils": "0.0.14", "otpauth": "9.5.1", - "pg": "8.22.0", + "pg": "8.23.0", "pkce-challenge": "6.0.0", "probe-image-size": "7.3.0", "promise-limit": "2.7.0", "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.25.0", + "re2": "1.26.1", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", - "sanitize-html": "2.17.5", + "sanitize-html": "2.17.6", "secure-json-parse": "4.1.0", "semver": "7.8.5", - "sharp": "0.35.2", + "sharp": "0.35.3", "slacc": "0.1.5", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.31.11", + "systeminformation": "5.33.1", "tinycolor2": "1.6.0", "tmp": "0.2.7", - "tsc-alias": "1.8.17", - "typeorm": "1.0.0", + "tsc-alias": "1.9.1", + "typeorm": "1.1.0", "ulid": "3.0.2", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.21.0", + "ws": "8.21.3", "xev": "3.0.2" }, "devDependencies": { "@kitajs/ts-html-plugin": "4.1.4", - "@nestjs/platform-express": "11.1.27", + "@nestjs/platform-express": "11.1.29", "@rollup/plugin-esm-shim": "0.1.8", - "@sentry/vue": "10.62.0", + "@sentry/vue": "10.70.0", "@sinonjs/fake-timers": "15.4.0", "@types/accepts": "1.3.7", "@types/archiver": "8.0.0", @@ -159,15 +159,15 @@ "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", "@types/ms": "2.1.0", - "@types/node": "26.0.1", + "@types/node": "26.2.0", "@types/nodemailer": "8.0.1", - "@types/pg": "8.20.0", + "@types/pg": "8.21.0", "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", "@types/sanitize-html": "2.16.1", - "@types/semver": "7.7.1", + "@types/semver": "7.8.0", "@types/simple-oauth2": "5.0.8", "@types/sinonjs__fake-timers": "15.0.1", "@types/tinycolor2": "1.4.6", @@ -175,21 +175,21 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@vitest/coverage-v8": "4.1.9", + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@vitest/coverage-v8": "4.1.10", "aws-sdk-client-mock": "4.1.0", "cbor2": "2.3.0", "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", - "execa": "9.6.1", + "execa": "10.0.1", "fkill": "10.0.3", - "js-yaml": "5.2.0", + "js-yaml": "5.2.3", "pid-port": "2.1.1", - "rolldown": "1.1.3", + "rolldown": "1.2.3", "simple-oauth2": "5.1.0", - "vite": "8.1.0", - "vitest": "4.1.9", - "vitest-mock-extended": "4.0.0" + "vite": "8.2.1", + "vitest": "4.1.10", + "vitest-mock-extended": "5.1.1" } } diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index c454c915a03..76bfa35421a 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -159,7 +159,7 @@ export default defineConfig((args) => { clearScreen: false, }, // ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする - external: isWatchMode ? /^(?!@\/)[^.\/](?!:[\/\\])/ : externalModules, + external: isWatchMode ? /^(?!@\/|\0)[^.\/](?!:[\/\\])/ : externalModules, }; } }); diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts deleted file mode 100644 index 74b84d998ff..00000000000 --- a/packages/backend/scripts/measure-memory.mts +++ /dev/null @@ -1,631 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ChildProcess, fork } from 'node:child_process'; -import { setTimeout } from 'node:timers/promises'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { tmpdir } from 'node:os'; -//import * as http from 'node:http'; -import * as fs from 'node:fs/promises'; -import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -function readIntegerEnv(name, defaultValue, min) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); - - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); - return value; -} - -function readBooleanEnv(name, defaultValue) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (rawValue === '1' || rawValue === 'true') return true; - if (rawValue === '0' || rawValue === 'false') return false; - throw new Error(`${name} must be one of: 1, 0, true, false`); -} - -const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure -const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup -const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle -const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses -const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0); -const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); -const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); -const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; - -const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; -const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; - -type GcMessage = 'gc ok' | 'gc unavailable'; -type RuntimeMemoryUsageMessage = { - type: 'memory usage'; - value: NodeJS.MemoryUsage; -}; -type HeapSnapshotMessage = { - type: 'heap snapshot'; - path?: string; -}; -type HeapSnapshotErrorMessage = { - type: 'heap snapshot error'; - message: string; -}; -type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; - -function parseMemoryFile(content: string, keys: KS, path: string, required: boolean): Record { - const result = {} as Record; - for (const _key of keys) { - const key = _key as KS[number]; - const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); - if (match) { - result[key] = parseInt(match[1], 10); - } else if (required) { - throw new Error(`Failed to parse ${key} from ${path}`); - } - } - return result; -} - -function bytesToKiB(value: number) { - return Math.round(value / 1024); -} - -function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') { - const label = String(value ?? '').replace(/\s+/g, ' ').trim(); - if (label === '') return fallback; - if (label.length <= 80) return label; - return `${label.slice(0, 77)}...`; -} - -function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) { - if (category === 'strings') return type; - - if (category === 'jsArrays') { - if (type === 'array elements') return 'Array elements'; - if (type === 'object' && name === 'Array') return 'Array objects'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'typedArrays') { - if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'systemObjects') { - if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); - if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'otherJsObjects') { - if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); - return type; - } - - if (category === 'otherNonJsObjects') { - if (type === 'extra native bytes') return 'Extra native bytes'; - if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'code') { - const lowerName = name.toLowerCase(); - if (lowerName.includes('bytecode')) return 'bytecode'; - if (lowerName.includes('builtin')) return 'builtins'; - if (lowerName.includes('regexp')) return 'regexp code'; - if (lowerName.includes('stub')) return 'stubs'; - return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); - } - - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); -} - -function collapseHeapSnapshotBreakdown(breakdowns: Record>) { - const collapsed = {} as Record>; - - for (const [category, children] of Object.entries(breakdowns)) { - const entries = Object.entries(children) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const categoryBreakdown = Object.fromEntries(topEntries); - if (otherValue > 0) categoryBreakdown.Other = otherValue; - if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown; - } - - return collapsed; -} - -// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. -function analyzeHeapSnapshot(snapshot) { - const meta = snapshot?.snapshot?.meta; - const nodes = snapshot?.nodes; - const edges = snapshot?.edges; - const strings = snapshot?.strings; - if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { - throw new Error('Invalid heap snapshot format'); - } - - const nodeFields = meta.node_fields; - if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); - const edgeFields = meta.edge_fields; - if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); - - const typeOffset = nodeFields.indexOf('type'); - const nameOffset = nodeFields.indexOf('name'); - const selfSizeOffset = nodeFields.indexOf('self_size'); - const edgeCountOffset = nodeFields.indexOf('edge_count'); - if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { - throw new Error('Heap snapshot is missing required node fields'); - } - const edgeTypeOffset = edgeFields.indexOf('type'); - const edgeNameOffset = edgeFields.indexOf('name_or_index'); - const edgeToNodeOffset = edgeFields.indexOf('to_node'); - if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { - throw new Error('Heap snapshot is missing required edge fields'); - } - - const nodeTypeNames = meta.node_types?.[typeOffset]; - if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); - const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; - if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); - - function createEmptyHeapSnapshotCategoryMap() { - return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record; - } - - const nodeFieldCount = nodeFields.length; - const edgeFieldCount = edgeFields.length; - const nativeType = nodeTypeNames.indexOf('native'); - const codeType = nodeTypeNames.indexOf('code'); - const hiddenType = nodeTypeNames.indexOf('hidden'); - const stringTypes = new Set([ - nodeTypeNames.indexOf('string'), - nodeTypeNames.indexOf('concatenated string'), - nodeTypeNames.indexOf('sliced string'), - ]); - const internalEdgeType = edgeTypeNames.indexOf('internal'); - const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; - const categories = createEmptyHeapSnapshotCategoryMap(); - const nodeCounts = createEmptyHeapSnapshotCategoryMap(); - const breakdowns = Object.fromEntries( - (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) - .filter(category => category !== 'total') - .map(category => [category, {}]), - ); - - function addValue(map: Record, key: string, value: number) { - map[key] = (map[key] ?? 0) + value; - } - - const edgeStartIndexes = new Map(); - const retainerCounts = new Map(); - let edgeIndex = 0; - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - edgeStartIndexes.set(nodeIndex, edgeIndex); - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { - const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; - retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); - } - } - - const jsArrayElementNodeIndexes = new Set(); - - function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { - if (value <= 0) return; - categories[category] += value; - addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); - if (nodeIndex != null) nodeCounts[category]++; - } - - function addJsArrayElementSize(nodeIndex: number) { - const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { - const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; - if (edgeType !== internalEdgeType) continue; - - const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; - if (edgeName !== 'elements') continue; - - const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; - if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { - const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); - jsArrayElementNodeIndexes.add(elementsNodeIndex); - } - break; - } - } - - if (extraNativeBytes > 0) { - addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); - } - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - const typeId = nodes[nodeIndex + typeOffset]; - const type = nodeTypeNames[typeId] ?? 'unknown'; - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - categories.total += selfSize; - nodeCounts.total++; - - if (typeId === hiddenType) { - addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); - continue; - } - - if (typeId === nativeType) { - if (name === 'system / JSArrayBufferData') { - addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); - } else { - addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); - } - continue; - } - - if (typeId === codeType) { - addCategoryValue('code', selfSize, type, name, nodeIndex); - continue; - } - - if (stringTypes.has(typeId)) { - addCategoryValue('strings', selfSize, type, name, nodeIndex); - continue; - } - - if (name === 'Array') { - addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); - addJsArrayElementSize(nodeIndex); - continue; - } - } - - categories.total += extraNativeBytes; - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; - - const typeId = nodes[nodeIndex + typeOffset]; - if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; - - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - if (name === 'Array') continue; - - const type = nodeTypeNames[typeId] ?? 'unknown'; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); - } - - return { - categories, - nodeCounts, - breakdowns: collapseHeapSnapshotBreakdown(breakdowns), - }; -} - -async function getMemoryUsage(pid: number) { - const path = `/proc/${pid}/status`; - const status = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(status, procStatusKeys, path, true); -} - -async function getSmapsRollupMemoryUsage(pid: number) { - const path = `/proc/${pid}/smaps_rollup`; - const smapsRollup = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false); -} - -function isRecord(value: unknown): value is Record { - return value != null && typeof value === 'object'; -} - -function isGcMessage(message: unknown): message is GcMessage { - return message === 'gc ok' || message === 'gc unavailable'; -} - -function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { - return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); -} - -function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { - if (!isRecord(message)) return false; - if (message.type === 'heap snapshot') return true; - return message.type === 'heap snapshot error' && typeof message.message === 'string'; -} - -function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) { - return new Promise((resolve, reject) => { - const timer = globalThis.setTimeout(() => { - serverProcess.off('message', onMessage); - reject(new Error(`Timed out waiting for ${description}`)); - }, timeout); - - const onMessage = (message: unknown) => { - if (!predicate(message)) return; - globalThis.clearTimeout(timer); - serverProcess.off('message', onMessage); - resolve(message); - }; - - serverProcess.on('message', onMessage); - }); -} - -async function getRuntimeMemoryUsage(serverProcess: ChildProcess) { - const response = waitForMessage( - serverProcess, - isRuntimeMemoryUsageMessage, - 'memory usage', - ); - - serverProcess.send('memory usage'); - - const message = await response; - const memoryUsage = message.value; - - return { - HeapTotal: bytesToKiB(memoryUsage.heapTotal), - HeapUsed: bytesToKiB(memoryUsage.heapUsed), - External: bytesToKiB(memoryUsage.external), - ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers), - }; -} - -async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { - if (!HEAP_SNAPSHOT) return null; - - const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); - const response = waitForMessage( - serverProcess, - isHeapSnapshotResponseMessage, - 'heap snapshot', - HEAP_SNAPSHOT_TIMEOUT, - ); - - serverProcess.send({ - type: 'heap snapshot', - path: snapshotPath, - }); - - const message = await response; - if (message.type === 'heap snapshot error') { - throw new Error(`Failed to write heap snapshot: ${message.message}`); - } - - const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath; - - try { - if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') { - await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true }); - await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH); - } - - const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); - return analyzeHeapSnapshot(snapshot); - } finally { - await fs.unlink(writtenPath).catch(err => { - process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); - }); - } -} - -async function getAllMemoryUsage(serverProcess: ChildProcess) { - const pid = serverProcess.pid!; - return { - ...await getMemoryUsage(pid), - ...await getSmapsRollupMemoryUsage(pid), - ...await getRuntimeMemoryUsage(serverProcess), - }; -} - -async function measureMemory() { - // Start the Misskey backend server using fork to enable IPC - const serverProcess = fork(join(__dirname, '../built/entry.js'), [], { - cwd: join(__dirname, '..'), - env: { - ...process.env, - NODE_ENV: 'production', - MK_DISABLE_CLUSTERING: '1', - MK_ONLY_SERVER: '1', - MK_NO_DAEMONS: '1', - }, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - execArgv: [...process.execArgv, '--expose-gc'], - }); - - let serverReady = false; - - // Listen for the 'ok' message from the server indicating it's ready - serverProcess.on('message', (message) => { - if (message === 'ok') { - serverReady = true; - } - }); - - // Handle server output - serverProcess.stdout?.on('data', (data) => { - process.stderr.write(`[server stdout] ${data}`); - }); - - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(`[server stderr] ${data}`); - }); - - // Handle server error - serverProcess.on('error', (err) => { - process.stderr.write(`[server error] ${err}\n`); - }); - - async function triggerGc() { - const ok = waitForMessage( - serverProcess, - isGcMessage, - 'GC completion', - ); - - serverProcess.send('gc'); - - const message = await ok; - if (message === 'gc unavailable') { - throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); - } - - await setTimeout(1000); - } - - //function createRequest() { - // return new Promise((resolve, reject) => { - // const req = http.request({ - // host: 'localhost', - // port: 61812, - // path: '/api/meta', - // method: 'POST', - // }, (res) => { - // res.on('data', () => { }); - // res.on('end', () => { - // resolve(); - // }); - // }); - // req.on('error', (err) => { - // reject(err); - // }); - // req.end(); - // }); - //} - - // Wait for server to be ready or timeout - const startupStartTime = Date.now(); - while (!serverReady) { - if (Date.now() - startupStartTime > STARTUP_TIMEOUT) { - serverProcess.kill('SIGTERM'); - throw new Error('Server startup timeout'); - } - await setTimeout(100); - } - - const startupTime = Date.now() - startupStartTime; - process.stderr.write(`Server started in ${startupTime}ms\n`); - - // Wait for memory to settle - await setTimeout(MEMORY_SETTLE_TIME); - - //const beforeGc = await getAllMemoryUsage(serverProcess); - - await triggerGc(); - - const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess); - - //// create some http requests to simulate load - //await Promise.all( - // Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), - //); - - //await triggerGc(); - - //const afterRequest = await getAllMemoryUsage(serverProcess); - - const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess); - - // Stop the server - serverProcess.kill('SIGTERM'); - - // Wait for process to exit - let exited = false; - await new Promise((resolve) => { - serverProcess.on('exit', () => { - exited = true; - resolve(undefined); - }); - // Force kill after 10 seconds if not exited - setTimeout(10000).then(() => { - if (!exited) { - serverProcess.kill('SIGKILL'); - } - resolve(undefined); - }); - }); - - const result = { - timestamp: new Date().toISOString(), - phases: { - //beforeGc, - afterGc: { - memoryUsage: memoryUsageAfterGC, - heapSnapshot: heapSnapshotAfterGc, - }, - //afterRequest, - }, - }; - - return result; -} - -export type MemoryReportRaw = { - timestamp: string; - sampleCount: number; - measurement: { - startupTimeoutMs: number; - memorySettleTimeMs: number; - ipcTimeoutMs: number; - requestCount: number; - heapSnapshot: { - enabled: boolean; - timeoutMs: number; - breakdownTopN: number; - }; - }; - samples: Awaited>[]; -}; - -async function main() { - const results = []; - for (let i = 0; i < SAMPLE_COUNT; i++) { - process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`); - const res = await measureMemory(); - results.push(res); - } - - const result: MemoryReportRaw = { - timestamp: new Date().toISOString(), - sampleCount: SAMPLE_COUNT, - measurement: { - startupTimeoutMs: STARTUP_TIMEOUT, - memorySettleTimeMs: MEMORY_SETTLE_TIME, - ipcTimeoutMs: IPC_TIMEOUT, - requestCount: REQUEST_COUNT, - heapSnapshot: { - enabled: HEAP_SNAPSHOT, - timeoutMs: HEAP_SNAPSHOT_TIMEOUT, - breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N, - }, - }, - samples: results, - }; - - // Output as JSON to stdout - console.log(JSON.stringify(result, null, 2)); -} - -main().catch((err) => { - console.error(JSON.stringify({ - error: err.message, - timestamp: new Date().toISOString(), - })); - process.exit(1); -}); diff --git a/packages/backend/scripts/watch.mjs b/packages/backend/scripts/watch.mjs index 9d608b233c5..a0ccea3b16b 100644 --- a/packages/backend/scripts/watch.mjs +++ b/packages/backend/scripts/watch.mjs @@ -21,7 +21,7 @@ import { execa } from 'execa'; }); }, 3000); - execa('tsgo', ['-w', '-p', 'tsconfig.json'], { + execa('tsc', ['-w', '-p', 'tsconfig.json'], { stdout: process.stdout, stderr: process.stderr, }); diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 9fcb4d73caf..03e5c395c36 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -10,10 +10,11 @@ import cluster from 'node:cluster'; import { EventEmitter } from 'node:events'; import { writeHeapSnapshot } from 'node:v8'; -import chalk from 'chalk'; import Xev from 'xev'; import Logger from '@/logger.js'; import { envOption } from '../env.js'; +import { installProcessErrorHandlers } from './process-error-handler.js'; +import { isShutdownInProgress } from './shutdown-handler.js'; import { readyRef } from './ready.js'; import 'reflect-metadata'; @@ -27,6 +28,8 @@ const logger = new Logger('core', 'cyan'); const clusterLogger = logger.createSubLogger('cluster', 'orange'); const ev = new Xev(); +installProcessErrorHandlers({ logger, quiet: envOption.quiet }); + //#region Events // Listen new workers @@ -41,27 +44,19 @@ cluster.on('online', worker => { // Listen for dying workers cluster.on('exit', worker => { - // Replace the dead worker, - // we're not sentimental - clusterLogger.error(chalk.red(`[${worker.id}] died :(`)); - cluster.fork(); -}); - -// Display detail of unhandled promise rejection -if (!envOption.quiet) { - process.on('unhandledRejection', console.dir); -} + if (isShutdownInProgress()) { + clusterLogger.info(`Process exited during shutdown: [${worker.id}]`); + return; + } -// Display detail of uncaught exception -process.on('uncaughtException', err => { - try { - logger.error(err); - console.trace(err); - } catch { } + // 終了したワーカーは従来どおり再生成し、表示色は出力処理へ任せます。 + clusterLogger.error(`[${worker.id}] died :(`); + cluster.fork(); }); // Dying away... process.on('exit', code => { + if (isShutdownInProgress()) return; logger.info(`The process is going to exit with code ${code}`); }); @@ -69,12 +64,10 @@ process.on('exit', code => { if (!envOption.disableClustering) { if (cluster.isPrimary) { - logger.info(`Start main process... pid: ${process.pid}`); const { masterMain } = await import('./master.js'); await masterMain(); ev.mount(); } else if (cluster.isWorker) { - logger.info(`Start worker process... pid: ${process.pid}`); const { workerMain } = await import('./worker.js'); await workerMain(); } else { @@ -82,7 +75,6 @@ if (!envOption.disableClustering) { } } else { // 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない) - logger.info(`Start main process... pid: ${process.pid}`); const { masterMain } = await import('./master.js'); await masterMain(); ev.mount(); diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index aac68cb02f9..3cb340e9637 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -11,17 +11,30 @@ import chalkTemplate from 'chalk-template'; import Logger from '@/logger.js'; import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; +import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js'; +import type { LogFormat } from '@/logging/types.js'; import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; -import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; +import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; +import { installShutdownSignalHandlers } from './shutdown-handler.js'; const logger = new Logger('core', 'cyan'); const bootLogger = logger.createSubLogger('boot', 'magenta'); const themeColor = chalk.hex('#86b300'); -function greet(props: { version: string }) { +/** 起動時の案内を、選択されたログ形式に合わせて出力します。 */ +function greet(props: { version: string; format: LogFormat }) { + if (!envOption.quiet && props.format === 'json') { + // JSONモードでは生のコンソール出力を避け、各案内を1件ずつ構造化ログにします。 + bootLogger.info('Welcome to Misskey!'); + bootLogger.info(`Misskey v${props.version}`, null, true); + bootLogger.info('Misskey is an open-source decentralized microblogging platform.'); + bootLogger.info('If you like Misskey, please consider donating to support dev. https://misskey-hub.net/docs/donate/'); + return; + } + if (!envOption.quiet) { //#region Misskey logo const v = `v${props.version}`; @@ -52,7 +65,9 @@ export async function masterMain() { // initialize app try { config = loadConfigBoot(); - greet({ version: config.version }); + logger.info(`Start main process... pid: ${process.pid}`); + bootLogger.createSubLogger('config').succ('Loaded'); + greet({ version: config.version, format: config.logging?.format ?? 'pretty' }); showEnvironment(); await showMachineInfo(bootLogger); showNodejsVersion(); @@ -67,7 +82,16 @@ export async function masterMain() { initExtraThreadPool(config); - await initTelemetry(config); + try { + await initTelemetry(config); + } catch (e) { + bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); + process.exit(1); + } + installShutdownSignalHandlers({ + shutdownTasks: [shutdownTelemetry, shutdownLogging], + onRegistered: message => bootLogger.info(message), + }); bootLogger.info( `mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`, @@ -125,12 +149,14 @@ function showNodejsVersion(): void { nodejsLogger.info(`Version ${process.version} detected.`); } +/** 設定を読み込み、成功時に後続のログ出力形式を適用します。 */ function loadConfigBoot(): Config { const configLogger = bootLogger.createSubLogger('config'); let config; try { config = loadConfig(); + configureLogging(config.logging); } catch (exception) { if (typeof exception === 'string') { configLogger.error(exception); @@ -142,8 +168,6 @@ function loadConfigBoot(): Config { throw exception; } - configLogger.succ('Loaded'); - return config; } diff --git a/packages/backend/src/boot/process-error-handler.ts b/packages/backend/src/boot/process-error-handler.ts new file mode 100644 index 00000000000..c1224f1923f --- /dev/null +++ b/packages/backend/src/boot/process-error-handler.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogWriteInput } from '@/logging/types.js'; + +/** プロセス例外をロガーへ渡すために必要な最小の処理対象です。 */ +export type ProcessErrorHandlerProcess = { + on(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown; + on(event: 'uncaughtException', listener: (error: Error) => void): unknown; +}; + +/** 例外記録に必要なロガーの最小インターフェースです。 */ +export type ProcessErrorHandlerLogger = { + write(input: LogWriteInput): void; +}; + +/** プロセス例外ハンドラーの登録に使う依存関係です。 */ +export type ProcessErrorHandlerOptions = { + readonly process?: ProcessErrorHandlerProcess; + readonly logger: ProcessErrorHandlerLogger; + readonly quiet: boolean; +}; + +/** ログ記録の失敗が別の例外を起こさないよう、プロセス異常を安全に記録します。 */ +function writeProcessError( + logger: ProcessErrorHandlerLogger, + eventName: 'process.unhandled_rejection' | 'process.uncaught_exception', + message: string, + error: unknown, +): void { + try { + // 元の値をerrorへ渡し、JSON形式の出力処理で安全な形へ正規化します。 + logger.write({ + level: 'error', + eventName, + message, + error, + }); + } catch { + // 例外処理中のログ失敗で、さらにプロセスを不安定にしないよう握りつぶします。 + } +} + +/** 未処理のPromise拒否と未捕捉例外を構造化ログへ接続します。 */ +export function installProcessErrorHandlers(options: ProcessErrorHandlerOptions): void { + const processLike: ProcessErrorHandlerProcess = options.process ?? (process as unknown as ProcessErrorHandlerProcess); + + if (!options.quiet) { + processLike.on('unhandledRejection', reason => { + writeProcessError(options.logger, 'process.unhandled_rejection', 'Unhandled promise rejection', reason); + }); + } + + processLike.on('uncaughtException', error => { + writeProcessError(options.logger, 'process.uncaught_exception', 'Uncaught exception', error); + }); +} diff --git a/packages/backend/src/boot/shutdown-handler.ts b/packages/backend/src/boot/shutdown-handler.ts new file mode 100644 index 00000000000..a0d85d1df69 --- /dev/null +++ b/packages/backend/src/boot/shutdown-handler.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type ShutdownSignalProcess = { + once(event: 'SIGTERM' | 'SIGINT', listener: () => Promise): unknown; +}; + +const SHUTDOWN_TIMEOUT_MS = 10_000; + +export type ShutdownTask = () => Promise; + +export type ShutdownHandlerOptions = { + /** The process-like object that receives the signal handlers. */ + process?: ShutdownSignalProcess; + /** Shutdown tasks, executed in array order. */ + shutdownTasks: readonly ShutdownTask[]; + /** Process termination function. */ + exit?: (code: number) => void; + /** Optional boot logger hook used after signal handlers are registered. */ + onRegistered?: (message: string) => void; +}; + +let shuttingDown = false; + +/** + * Register the process-level shutdown signals. + * + * Boot owns signal coordination and receives shutdown tasks through callbacks + * so individual domains do not depend on each other. + * + * 注意: このプロジェクトでは app.enableShutdownHooks() が一切呼ばれていないため、 + * NestJSのOnApplicationShutdown経由のgraceful shutdown(GlobalModule.dispose()によるDB/Redis切断、 + * QueueProcessorService.stop()によるqueue drain、ServerService.dispose()によるfastify/WebSocket close)は + * SIGTERM/SIGINTを起点には発火しない。このhandlerはそれらを経由せず、登録された終了処理を実行して即exitする。 + * 将来enableShutdownHooks()を配線する場合は、この即exitとNestJS側のshutdown sequenceが競合しないよう順序を設計すること。 + */ +export function installShutdownSignalHandlers(options: ShutdownHandlerOptions): void { + // テストではprocess/exitを差し替え、本番では実processにSIGTERM/SIGINT handlerを登録する。 + const processLike = options.process ?? process; + const exit = options.exit ?? ((code: number) => process.exit(code)); + + const handleSignal = async () => { + // 同時に複数signalが来てもflushを二重実行せず、cluster refork抑止用の状態もここで立てる。 + if (shuttingDown) return; + shuttingDown = true; + + let timedOut = false; + let timeout: NodeJS.Timeout | undefined; + try { + // 処理時間上限つきのシャットダウンプロセス + await Promise.race([ + (async () => { + for (const shutdownTask of options.shutdownTasks) { + if (timedOut) return; + try { + await shutdownTask(); + } catch (error) { + // 1つの終了処理の失敗で後続タスクを妨げないよう、stderrへフォールバックする。 + try { + console.error('Shutdown task failed:', error); + } catch { + // stderrの出力自体が失敗しても、残りの終了処理とexitは継続する。 + } + } + } + })(), + new Promise(resolve => { + timeout = setTimeout(() => { + timedOut = true; + try { + console.error(`Shutdown tasks timed out after ${SHUTDOWN_TIMEOUT_MS}ms.`); + } catch { + // stderrの出力自体が失敗してもexitは継続する。 + } + resolve(); + }, SHUTDOWN_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout != null) clearTimeout(timeout); + } + + // 既存挙動と同じく、終了処理後はプロセスを終了する。 + exit(0); + }; + + // onceにして、同じsignalでhandlerが再入しないようにする。 + processLike.once('SIGTERM', handleSignal); + processLike.once('SIGINT', handleSignal); + + // app.enableShutdownHooks()未配線の現状、SIGTERM/SIGINT時には登録済み終了処理のみを行う。 + options.onRegistered?.('Registered SIGTERM/SIGINT shutdown handler (this process does not perform NestJS graceful shutdown on these signals).'); +} + +export function isShutdownInProgress(): boolean { + // masterのcluster exit handlerが、意図したshutdown中のworker終了を再forkしないために参照する。 + return shuttingDown; +} diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index 00d5dd90634..71857f9a527 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -4,20 +4,45 @@ */ import cluster from 'node:cluster'; +import Logger from '@/logger.js'; import { envOption } from '@/env.js'; import { loadConfig } from '@/config.js'; -import { initTelemetry } from '@/core/telemetry/telemetry-registry.js'; +import type { Config } from '@/config.js'; +import { configureLogging, shutdownLogging } from '@/logging/logging-runtime.js'; +import { initTelemetry, shutdownTelemetry } from '@/core/telemetry/telemetry-registry.js'; import { initExtraThreadPool, jobQueue, server } from './common.js'; +import { installShutdownSignalHandlers } from './shutdown-handler.js'; + +const logger = new Logger('core', 'cyan'); +const bootLogger = logger.createSubLogger('boot', 'magenta'); /** * Init worker process */ export async function workerMain() { - const config = loadConfig(); + let config: Config; + try { + config = loadConfig(); + configureLogging(config.logging); + logger.info(`Start worker process... pid: ${process.pid}`); + } catch (e) { + bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); + process.exit(1); + return; + } initExtraThreadPool(config); - await initTelemetry(config); + try { + await initTelemetry(config); + } catch (e) { + bootLogger.error(e instanceof Error ? e : new Error(String(e)), null, true); + process.exit(1); + } + installShutdownSignalHandlers({ + shutdownTasks: [shutdownTelemetry, shutdownLogging], + onRegistered: message => bootLogger.info(message), + }); if (envOption.onlyServer) { await server(); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 439148d9fed..a50c8d5315d 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -9,9 +9,11 @@ import { dirname, resolve } from 'node:path'; 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 { RedisOptions as IoRedisRedisOptions } from 'ioredis'; +import type { RedisOptions as BullMqRedisOptions } from 'bullmq'; +import type { AccessLogConfiguration, LogFormat, LogLevelSetting } from './logging/types.js'; -type RedisOptionsSource = Partial & { +type RedisOptionsRequiredFields = { host: string; port: number; family?: number; @@ -19,6 +21,8 @@ type RedisOptionsSource = Partial & { db?: number; prefix?: string; }; +type RedisOptionsSource = Partial & RedisOptionsRequiredFields; +type RedisOptionsResolved = IoRedisRedisOptions & BullMqRedisOptions & RedisOptionsRequiredFields; type SentryBackendConfig = { options: Partial; @@ -138,6 +142,10 @@ type Source = { pidFile: string; logging?: { + format?: LogFormat; + level?: LogLevelSetting; + domains?: Record | null; + access?: AccessLogConfiguration; sql?: { disableQueryTruncation?: boolean, enableQueryParamLogging?: boolean, @@ -200,6 +208,10 @@ export type Config = { deliverJobMaxAttempts: number | undefined; inboxJobMaxAttempts: number | undefined; logging?: { + format?: LogFormat; + level?: LogLevelSetting; + domains?: Record | null; + access?: AccessLogConfiguration; sql?: { disableQueryTruncation?: boolean, enableQueryParamLogging?: boolean, @@ -224,11 +236,11 @@ export type Config = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; - redis: RedisOptions & RedisOptionsSource; - redisForPubsub: RedisOptions & RedisOptionsSource; - redisForJobQueue: RedisOptions & RedisOptionsSource; - redisForTimelines: RedisOptions & RedisOptionsSource; - redisForReactions: RedisOptions & RedisOptionsSource; + redis: RedisOptionsResolved; + redisForPubsub: RedisOptionsResolved; + redisForJobQueue: RedisOptionsResolved; + redisForTimelines: RedisOptionsResolved; + redisForReactions: RedisOptionsResolved; sentryForBackend: SentryBackendConfig | undefined; sentryForFrontend: { options: Partial & { dsn: string }; @@ -405,7 +417,7 @@ function tryCreateUrl(url: string) { } } -function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource { +function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptionsResolved { return { ...options, password: options.pass, diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 7dae8d10f39..54198fdc06c 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; import type { MiLocalUser } from '@/models/User.js'; import { RedisKVCache } from '@/misc/cache.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @Injectable() export class ChannelFollowingService implements OnModuleInit { @@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit { requestUser: MiLocalUser, targetChannel: MiChannel, ): Promise { - await this.channelFollowingsRepository.insert({ - id: this.idService.gen(), - followerId: requestUser.id, - followeeId: targetChannel.id, - }); + try { + await this.channelFollowingsRepository.insert({ + id: this.idService.gen(), + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + } catch (e) { + if (isDuplicateKeyValueError(e)) { + throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following'); + } + throw e; + } this.globalEventService.publishInternalEvent('followChannel', { userId: requestUser.id, diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index eda33d2e701..e581b697367 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -18,7 +18,7 @@ import { FlashService } from '@/core/FlashService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; -import { AiService } from './AiService.js'; +import { SensitiveMediaDetectionService } from './SensitiveMediaDetectionService.js'; import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; import { AchievementService } from './AchievementService.js'; @@ -173,7 +173,7 @@ const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisti const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService }; const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; -const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; +const $SensitiveMediaDetectionService: Provider = { provide: 'SensitiveMediaDetectionService', useExisting: SensitiveMediaDetectionService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; @@ -334,7 +334,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AbuseReportNotificationService, AccountMoveService, AccountUpdateService, - AiService, + SensitiveMediaDetectionService, AnnouncementService, AntennaService, AchievementService, @@ -494,7 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AbuseReportNotificationService, $AccountMoveService, $AccountUpdateService, - $AiService, + $SensitiveMediaDetectionService, $AnnouncementService, $AntennaService, $AchievementService, @@ -652,7 +652,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AbuseReportNotificationService, AccountMoveService, AccountUpdateService, - AiService, + SensitiveMediaDetectionService, AnnouncementService, AntennaService, AchievementService, @@ -811,7 +811,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AbuseReportNotificationService, $AccountMoveService, $AccountUpdateService, - $AiService, + $SensitiveMediaDetectionService, $AnnouncementService, $AntennaService, $AchievementService, diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 516eb745142..f0befc2a855 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -16,12 +16,12 @@ import probeImageSize from 'probe-image-size'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import * as blurhash from 'blurhash'; import { createTempDir } from '@/misc/create-temp.js'; -import { AiService } from '@/core/AiService.js'; +import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; -import type { Prediction } from '@/core/AiService.js'; +import type { Prediction } from '@/core/SensitiveMediaDetectionService.js'; export type FileInfo = { size: number; @@ -54,7 +54,7 @@ export class FileInfoService { private logger: Logger; constructor( - private aiService: AiService, + private sensitiveMediaDetectionService: SensitiveMediaDetectionService, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('file-info'); @@ -266,7 +266,7 @@ export class FileInfoService { fs.promises.unlink(path); } } - const predictions = await this.aiService.detectSensitiveMany(frameBuffers); + const predictions = await this.sensitiveMediaDetectionService.detectSensitiveMany(frameBuffers); const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x)); // 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、 // Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、 @@ -291,7 +291,7 @@ export class FileInfoService { .flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす .png() .toBuffer(); - const result = await this.aiService.detectSensitive(png); + const result = await this.sensitiveMediaDetectionService.detectSensitive(png); if (result) { [sensitive, porn] = judgePrediction(result); } diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index beed786d1b9..750fbb06ffe 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import Logger from '../logger.js'; const logger = new Logger('hashtag/create'); +type AttachedOrMentioned = 'attached' | 'mentioned'; +type UpdatingHashtagColumn = { + totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`, + totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`, + localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`, + localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`, + remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`, + remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`, +}; + @Injectable() export class HashtagService { constructor( @@ -68,126 +77,93 @@ export class HashtagService { // TODO: サンプリング this.updateHashtagsRanking(tag, user.id); - { - const index = await this.hashtagsRepository.findOneBy({ name: tag }); - - if (index == null && inc) { - try { - if (isUserAttached) { - await this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [], - mentionedUsersCount: 0, - mentionedLocalUserIds: [], - mentionedLocalUsersCount: 0, - mentionedRemoteUserIds: [], - mentionedRemoteUsersCount: 0, - attachedUserIds: [user.id], - attachedUsersCount: 1, - attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - } as MiHashtag); - } else { - await this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [user.id], - mentionedUsersCount: 1, - mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - attachedUserIds: [], - attachedUsersCount: 0, - attachedLocalUserIds: [], - attachedLocalUsersCount: 0, - attachedRemoteUserIds: [], - attachedRemoteUsersCount: 0, - } as MiHashtag); - } - return; - } catch (err) { - if (isDuplicateKeyValueError(err)) { - logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`); - } else { - throw err; - } - } - } + const column: UpdatingHashtagColumn = isUserAttached ? { + totalUserIds: 'attachedUserIds', + totalUsersCount: 'attachedUsersCount', + localUserIds: 'attachedLocalUserIds', + localUsersCount: 'attachedLocalUsersCount', + remoteUserIds: 'attachedRemoteUserIds', + remoteUsersCount: 'attachedRemoteUsersCount', + } : { + totalUserIds: 'mentionedUserIds', + totalUsersCount: 'mentionedUsersCount', + localUserIds: 'mentionedLocalUserIds', + localUsersCount: 'mentionedLocalUsersCount', + remoteUserIds: 'mentionedRemoteUserIds', + remoteUsersCount: 'mentionedRemoteUsersCount', + }; + + if (inc) { + await this.#incrementHashTag(user, tag, column); + } else { + await this.#decrementHashTag(user, tag, column); } + } - await this.db.transaction(async transactionalEntityManager => { - const transactionalHashtagRepository = transactionalEntityManager - .getRepository(MiHashtag); - - const index = await transactionalHashtagRepository - .createQueryBuilder() - .setLock('pessimistic_write') - .where('name = :name', { name: tag }) - .getOne(); - - if (index == null) return; - - const set = {} as any; - - if (isUserAttached) { - if (inc) { - // 自分が初めてこのタグを使ったなら - if (!index.attachedUserIds.some(id => id === user.id)) { - set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => '"attachedUsersCount" + 1'; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { - set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1'; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { - set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1'; - } - } else { - set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; - set.attachedUsersCount = () => '"attachedUsersCount" - 1'; - if (this.userEntityService.isLocalUser(user)) { - set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; - set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1'; - } else { - set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; - set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1'; - } - } - } else { - // 自分が初めてこのタグを使ったなら - if (!index.mentionedUserIds.some(id => id === user.id)) { - set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; - set.mentionedUsersCount = () => '"mentionedUsersCount" + 1'; - } - // 自分が(ローカル内で)初めてこのタグを使ったなら - if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { - set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; - set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1'; - } - // 自分が(リモートで)初めてこのタグを使ったなら - if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { - set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; - set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1'; - } - } + async #incrementHashTag( + user: { id: MiUser['id']; host: MiUser['host']; }, + tag: string, + columns: UpdatingHashtagColumn, + ) { + const isLocal = this.userEntityService.isLocalUser(user); + const { totalUserIds, totalUsersCount } = columns; + const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds; + const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount; + + const runner = this.db.createQueryRunner('master'); + try { + await runner.query( + `INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}", + "${localOrRemoteUserCount}") + VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1) + ON CONFLICT ("name") + DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)}, + "${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)}, + "${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)}, + "${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`, + [tag, user.id, this.idService.gen()], + ); + } finally { + await runner.release(); + } - if (Object.keys(set).length > 0) { - await transactionalHashtagRepository - .createQueryBuilder() - .update() - .where('id = :id', { id: index.id }) - .set(set) - .execute(); - } - }); + function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string { + return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`; + } + + function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string { + return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`; + } + } + + async #decrementHashTag( + user: { id: MiUser['id']; host: MiUser['host']; }, + tag: string, + columns: UpdatingHashtagColumn, + ) { + const isLocal = this.userEntityService.isLocalUser(user); + const { totalUserIds, totalUsersCount } = columns; + const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds; + const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount; + + const runner = this.db.createQueryRunner('master'); + try { + await runner.query( + `UPDATE "hashtag" + SET "${totalUserIds}" = array_remove("${totalUserIds}", $2), + "${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)}, + "${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2), + "${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)} + WHERE "name" = $1`, + [tag, user.id], + ); + } finally { + await runner.release(); + } + + function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string { + return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`; + } } @bindThis diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 5714bde8bf1..f7c76f4705d 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; import type { IObject } from '@/core/activitypub/type.js'; -import type { Response } from 'node-fetch'; +import type { BodyInit, Response } from 'node-fetch'; import type { URL } from 'node:url'; export type HttpRequestSendOptions = { @@ -311,7 +311,7 @@ export class HttpRequestService { url: string, args: { method?: string, - body?: string, + body?: BodyInit, headers?: Record, timeout?: number, size?: number, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a54c60a0c60..7ac201796cd 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -520,6 +520,11 @@ export class NoteCreateService implements OnApplicationShutdown { // specified / direct noteはreject throw new Error('Renote target is not public or home'); } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } } // Check blocking @@ -534,19 +539,35 @@ export class NoteCreateService implements OnApplicationShutdown { } } - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } + if (data.reply) { + switch (data.reply.visibility) { + case 'public': + // public noteは無条件にreply可能 + break; + case 'home': + // home noteはhome以下にreply可能 + if (data.visibility === 'public') { + data.visibility = 'home'; + } + break; + case 'followers': + // followers noteはfollowers以下にreply可能 + if (data.visibility === 'public' || data.visibility === 'home') { + data.visibility = 'followers'; + } + break; + case 'specified': + // specified / direct noteはspecifiedのみreply可能 + if (data.visibility !== 'specified') { + data.visibility = 'specified'; + } + break; + } - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; + // ローカルのみにリプライしたらローカルのみにする + if (data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } } if (data.text) { diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts index daf54e8d7b5..57180af9dd8 100644 --- a/packages/backend/src/core/NoteUpdateService.ts +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -4,8 +4,10 @@ */ import { setImmediate } from 'node:timers/promises'; +import util from 'util'; import * as mfm from 'mfm-js'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { In } from 'typeorm'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; @@ -21,9 +23,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { RelayService } from '@/core/RelayService.js'; -import { In } from 'typeorm'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import util from 'util'; export type NoteUpdateData = { updatedAt?: Date | null; @@ -141,7 +141,6 @@ export class NoteUpdateService implements OnApplicationShutdown { //#region AP deliver if (this.userEntityService.isLocalUser(user)) { setImmediate(async () => { - // @ts-ignore const noteActivity = await this.renderNoteActivity(updatedNote, user); await this.deliverToConcerned(user, updatedNote, noteActivity); }); diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts index 4abeb30fce3..ebabe0570db 100644 --- a/packages/backend/src/core/PageService.ts +++ b/packages/backend/src/core/PageService.ts @@ -141,8 +141,6 @@ export class PageService { eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null), }); - console.log('page.content', page.content); - if (body.content != null) { const beforeReferencedNotes = this.collectReferencedNotes(page.content); const afterReferencedNotes = this.collectReferencedNotes(body.content); diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index ecd96261e0d..3c893ea4191 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -31,63 +31,67 @@ export type ObjectStorageQueue = Bull.Queue; export type UserWebhookDeliverQueue = Bull.Queue; export type SystemWebhookDeliverQueue = Bull.Queue; +function createQueue(queueName: string, config: Config): Bull.Queue { + return new Bull.Queue(queueName, baseQueueOptions(config, queueName)); +} + const $system: Provider = { provide: 'queue:system', - useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)), + useFactory: (config: Config) => createQueue>(QUEUE.SYSTEM, config), inject: [DI.config], }; const $endedPollNotification: Provider = { provide: 'queue:endedPollNotification', - useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)), + useFactory: (config: Config) => createQueue(QUEUE.ENDED_POLL_NOTIFICATION, config), inject: [DI.config], }; const $postScheduledNote: Provider = { provide: 'queue:postScheduledNote', - useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)), + useFactory: (config: Config) => createQueue(QUEUE.POST_SCHEDULED_NOTE, config), inject: [DI.config], }; const $deliver: Provider = { provide: 'queue:deliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), + useFactory: (config: Config) => createQueue(QUEUE.DELIVER, config), inject: [DI.config], }; const $inbox: Provider = { provide: 'queue:inbox', - useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)), + useFactory: (config: Config) => createQueue(QUEUE.INBOX, config), inject: [DI.config], }; const $db: Provider = { provide: 'queue:db', - useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)), + useFactory: (config: Config) => createQueue>(QUEUE.DB, config), inject: [DI.config], }; const $relationship: Provider = { provide: 'queue:relationship', - useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)), + useFactory: (config: Config) => createQueue(QUEUE.RELATIONSHIP, config), inject: [DI.config], }; const $objectStorage: Provider = { provide: 'queue:objectStorage', - useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)), + useFactory: (config: Config) => createQueue>(QUEUE.OBJECT_STORAGE, config), inject: [DI.config], }; const $userWebhookDeliver: Provider = { provide: 'queue:userWebhookDeliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)), + useFactory: (config: Config) => createQueue(QUEUE.USER_WEBHOOK_DELIVER, config), inject: [DI.config], }; const $systemWebhookDeliver: Provider = { provide: 'queue:systemWebhookDeliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)), + useFactory: (config: Config) => createQueue(QUEUE.SYSTEM_WEBHOOK_DELIVER, config), inject: [DI.config], }; diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index e416e3112ac..404eb1f6be0 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -905,7 +905,7 @@ export class QueueService { const isPaused = await queue.isPaused(); const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); - const db = parseRedisInfo(await (await queue.client).info()); + const db = parseRedisInfo(await (await queue.getBackend().client).info()); return { name: queueType, diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/SensitiveMediaDetectionService.ts similarity index 91% rename from packages/backend/src/core/AiService.ts rename to packages/backend/src/core/SensitiveMediaDetectionService.ts index fe21af09f47..22dcb5fc9bd 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/SensitiveMediaDetectionService.ts @@ -4,7 +4,6 @@ */ import { Injectable, Inject } from '@nestjs/common'; -import fetch from 'node-fetch'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -70,7 +69,7 @@ function isDetectImagesResponse(v: unknown): v is DetectImagesResponse { const DETECT_IMAGES_PATH = 'v1/detect-images'; @Injectable() -export class AiService { +export class SensitiveMediaDetectionService { private logger: Logger; constructor( @@ -80,7 +79,7 @@ export class AiService { private httpRequestService: HttpRequestService, private loggerService: LoggerService, ) { - this.logger = this.loggerService.getLogger('ai'); + this.logger = this.loggerService.getLogger('sensitive-media-detection'); } /** @@ -131,9 +130,6 @@ export class AiService { @bindThis private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - try { const form = new FormData(); for (let i = 0; i < chunk.length; i++) { @@ -148,14 +144,13 @@ export class AiService { headers['Authorization'] = `Bearer ${apiKey}`; } - const res = await fetch(url, { + const res = await this.httpRequestService.send(url, { method: 'POST', headers, body: form, - // 外部サービスとして通常の proxy / private address 制限を適用する。 - // サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。 - agent: (u) => this.httpRequestService.getAgentByUrl(u), - signal: controller.signal, + timeout, + }, { + throwErrorWhenResponseNotOk: false, }); if (!res.ok) { @@ -181,8 +176,6 @@ export class AiService { } catch (err) { this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`); return chunk.map(() => null); - } finally { - clearTimeout(timer); } } } diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 92d61cd103f..fa6f58b15ab 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; +import * as nodeCrypto from 'crypto'; import type { MiUser } from '@/models/User.js'; import type { UserKeypairsRepository } from '@/models/_.js'; import { RedisKVCache } from '@/misc/cache.js'; @@ -23,10 +24,10 @@ export class UserKeypairService implements OnApplicationShutdown { @Inject(DI.userKeypairsRepository) private userKeypairsRepository: UserKeypairsRepository, ) { - this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { + this.cache = new RedisKVCache(this.redisClient, 'userKeypair:v2', { lifetime: 1000 * 60 * 60 * 24, // 24h memoryCacheLifetime: 1000 * 60 * 60, // 1h - fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), + fetcher: (key) => this.fetcher(key), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => JSON.parse(value), }); @@ -46,4 +47,18 @@ export class UserKeypairService implements OnApplicationShutdown { public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } + + @bindThis + public async fetcher(userId: MiUser['id']): Promise { + const keyPair = await this.userKeypairsRepository.findOneByOrFail({ userId }); + + // migrate PKCS#1 => PKCS#8. legacy misskey generated PKCS#1 but slacc only accepts PKCS#8 + if (keyPair.privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) { + const pkcs8Key = nodeCrypto.createPrivateKey({ key: keyPair.privateKey, format: 'pem', type: 'pkcs1' }).export({ format: 'pem', type: 'pkcs8' }); + keyPair.privateKey = pkcs8Key; + void this.userKeypairsRepository.update(userId, { privateKey: pkcs8Key }); + } + + return keyPair; + } } diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts index b1858c31c11..126049e5205 100644 --- a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts +++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -3,14 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Config } from '@/config.js'; -import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js'; +import type { LogTraceContext } from '@/logging/types.js'; +import type * as SentryNode from '@sentry/node'; +import type { NodeOptions } from '@sentry/node'; +import type { SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js'; -type SentryIntegrationsOption = NonNullable; +// Sentryのtransportが詰まってもプロセス終了を妨げないようにする。 +const DEFAULT_SHUTDOWN_TIMEOUT = 5000; + +type SentryIntegrationsOption = NonNullable; // eslint-disable-next-line @typescript-eslint/no-explicit-any type SentryIntegrationFactory = Extract any[]>; type SentryIntegration = Parameters[0][number]; -type SentryNodeOptions = import('@sentry/node').NodeOptions; +type SentryNodeOptions = NodeOptions; type BuildSentryIntegrationsOptions = { disabledIntegrations?: string[]; @@ -37,7 +42,7 @@ export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions) } export function buildSentryNodeOptions( - config: NonNullable, + config: SentryBackendConfig, nodeProfilingIntegration?: () => SentryIntegration, ): SentryNodeOptions { return { @@ -65,11 +70,11 @@ export function buildSentryNodeOptions( export class SentryTelemetryAdapter implements TelemetryAdapter { private constructor( - private readonly Sentry: typeof import('@sentry/node'), + private readonly Sentry: typeof SentryNode, ) { } - public static async create(config: NonNullable): Promise { + public static async create(config: SentryBackendConfig): Promise { const Sentry = await import('@sentry/node'); const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); @@ -86,11 +91,21 @@ export class SentryTelemetryAdapter implements TelemetryAdapter { }); } + /** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */ + public getActiveTraceContext(): LogTraceContext | undefined { + const activeSpan = this.Sentry.getActiveSpan(); + if (activeSpan == null) return undefined; + + const { traceId, spanId, traceFlags } = activeSpan.spanContext(); + return { traceId, spanId, traceFlags }; + } + public startSpan(name: string, fn: () => T): T { return this.Sentry.startSpan({ name }, fn); } public async shutdown(): Promise { - await this.Sentry.close(); + // timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。 + await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT); } } diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts index d074de056d2..d17430d0c0c 100644 --- a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts +++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts @@ -3,19 +3,46 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { Config } from '@/config.js'; +import type { LogTraceContext } from '@/logging/types.js'; + +export type SentryBackendConfig = NonNullable; + export interface TelemetryCaptureMessageOptions { + /** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */ level: 'error'; + + /** Sentryではuser.idへ渡す補助情報です。 */ userId?: string; + + /** queue名やendpoint名など、通知先で調査に使う補助情報。 */ extra?: Record; } /** - * Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。 + * エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。 * 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、 * telemetry-registry.tsのinitTelemetry内で登録する。 */ export interface TelemetryAdapter { + /** + * 実行中の処理で起きたエラー相当の事象を記録する。 + * Sentryではmessage通知として扱う。 + */ captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void; + + /** 現在のactive Spanからログへ付加するTrace Contextを取得する。 */ + getActiveTraceContext?(): LogTraceContext | undefined; + + /** + * API endpointやqueue jobなど、呼び出し側の処理単位をspanで包む。 + * fnの戻り値・例外はそのまま呼び出し側へ返し、Promiseの場合はsettleまでspanを閉じない。 + */ startSpan(name: string, fn: () => T): T; + + /** + * プロセス終了時にtelemetry backendへ残りのデータをflushする。 + * 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。 + */ shutdown(): Promise; } diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts index 90dc2678374..aa17a97f765 100644 --- a/packages/backend/src/core/telemetry/telemetry-registry.ts +++ b/packages/backend/src/core/telemetry/telemetry-registry.ts @@ -4,6 +4,7 @@ */ import type { Config } from '@/config.js'; +import { setLogTraceContextProvider } from '@/logging/logging-runtime.js'; import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js'; import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; @@ -16,17 +17,27 @@ const adapters: TelemetryAdapter[] = []; export async function initTelemetry(config: Config): Promise { if (config.sentryForBackend) { - adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend)); + const adapter = await SentryTelemetryAdapter.create(config.sentryForBackend); + adapters.push(adapter); + // Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。 + setLogTraceContextProvider(() => adapter.getActiveTraceContext?.()); } } export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void { + // 有効なadapterすべてへ通知し、宛先ごとの差異はadapter内に閉じ込める。 for (const adapter of adapters) { adapter.captureMessage(message, opts); } } export function startSpan(name: string, fn: () => T): T { + // 有効なadapterが無い/1つだけの場合(実運用上の大半のケース)は、 + // 毎リクエスト/ジョブでreduceRightのclosureを組み立てる無駄を避ける。 + if (adapters.length === 0) return fn(); + if (adapters.length === 1) return adapters[0].startSpan(name, fn); + + // 将来複数adapterを登録する場合でも同じ処理を入れ子にラップし、呼び出し側のAPIは1回のstartSpanに保つ。 const wrapped = adapters.reduceRight<() => T>( (inner, adapter) => () => adapter.startSpan(name, inner), fn, @@ -35,5 +46,6 @@ export function startSpan(name: string, fn: () => T): T { } export async function shutdownTelemetry(): Promise { - await Promise.all(adapters.map(adapter => adapter.shutdown())); + // 終了時は登録済みadapterを並列にflush/shutdownする。 + await Promise.allSettled(adapters.map(adapter => adapter.shutdown())); } diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index ce76f8d05e5..4144c1cf046 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -3,109 +3,149 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import cluster from 'node:cluster'; -import chalk from 'chalk'; -import { default as convertColor } from 'color-convert'; -import { format as dateFormat } from 'date-fns'; import { bindThis } from '@/decorators.js'; -import { envOption } from './env.js'; +import { logManager } from './logging/logging-runtime.js'; +import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js'; import type { Keyword } from 'color-convert'; -type Context = { - name: string; - color?: Keyword; -}; - -type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; +// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。 +type LegacyData = Record | null; +/** + * ロガー名の階層と従来の公開APIを提供する薄い窓口です。 + * 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。 + */ // eslint-disable-next-line import/no-default-export export default class Logger { - private context: Context; - private parentLogger: Logger | null = null; + private context: readonly LoggerContext[]; + /** 指定した名前を起点とするLoggerを作成します。 */ constructor(context: string, color?: Keyword) { - this.context = { + this.context = [{ name: context, - color: color, - }; + color, + }]; } + /** + * 現在のロガーを親として、下位の名前を持つLoggerを作成します。 + */ @bindThis public createSubLogger(context: string, color?: Keyword): Logger { const logger = new Logger(context, color); - logger.parentLogger = this; + logger.context = [...this.context, ...logger.context]; return logger; } + /** + * 従来APIの引数を共通形式へ変換し、LogManagerへ渡します。 + */ @bindThis - private log(level: Level, message: string, data?: Record | null, important = false, subContexts: Context[] = []): void { - if (envOption.quiet) return; - - if (this.parentLogger) { - this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts)); - return; - } - - const time = dateFormat(new Date(), 'HH:mm:ss'); - const worker = cluster.isPrimary ? '*' : cluster.worker!.id; - const l = - level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : - level === 'warning' ? chalk.yellow('WARN') : - level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : - level === 'debug' ? chalk.gray('VERB') : - level === 'info' ? chalk.blue('INFO') : - null; - const contexts = [this.context].concat(subContexts).map(d => d.color ? chalk.rgb(...convertColor.keyword.rgb(d.color))(d.name) : chalk.white(d.name)); - const m = - level === 'error' ? chalk.red(message) : - level === 'warning' ? chalk.yellow(message) : - level === 'success' ? chalk.green(message) : - level === 'debug' ? chalk.gray(message) : - level === 'info' ? message : - null; + private log(level: LogLevel, message: string, data?: unknown, important = false, legacyLevel?: 'success', error?: unknown): void { + logManager.write({ + level, + message, + context: this.context, + ...(typeof error !== 'undefined' ? { error } : {}), + compatibility: { + legacyLevel, + important, + data, + }, + }); + } - let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`; - if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; + /** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */ + @bindThis + private logStructured(level: LogLevel, input: LogEntryInput): void { + this.write({ + ...input, + level, + }); + } - const args: unknown[] = [important ? chalk.bold(log) : log]; - if (data != null) { - args.push(data); - } - console.log(...args); + /** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */ + @bindThis + public write(input: LogWriteInput): void { + logManager.write({ + ...input, + context: this.context, + }); } + /** 処理を継続できない状況を記録します。 */ + 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 | 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); - } else if (typeof x === 'object') { - this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important); + this.log('error', x.toString(), data, important, undefined, x); + } 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 | null, important = false): void { // 実行を継続できるが改善すべき状況で使う - this.log('warning', 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表示で記録します。 */ + @bindThis + public succ(message: string, data?: Record | null, important = false): void { + this.log('info', message, data, important, 'success'); } + /** 開発者向けの調査情報を記録します。 */ + public debug(input: LogEntryInput): void; + public debug(message: string): void; + public debug(message: string, data?: LegacyData, important?: boolean): void; @bindThis - public succ(message: string, data?: Record | null, important = false): void { // 何かに成功した状況で使う - this.log('success', 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 debug(message: string, data?: Record | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) - if (process.env.NODE_ENV !== 'production' || envOption.verbose) { - this.log('debug', 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 info(message: string, data?: Record | null, important = false): void { // それ以外 - this.log('info', message, data, important); + public fatal(inputOrMessage: LogEntryInput | string): void { + if (typeof inputOrMessage === 'string') { + this.logStructured('fatal', { message: inputOrMessage }); + } else { + this.logStructured('fatal', inputOrMessage); + } } } diff --git a/packages/backend/src/logging/BootstrapConsoleBackend.ts b/packages/backend/src/logging/BootstrapConsoleBackend.ts new file mode 100644 index 00000000000..42016acef64 --- /dev/null +++ b/packages/backend/src/logging/BootstrapConsoleBackend.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogBackend } from './LogBackend.js'; +import type { AccessLogRecord, LogRecord } from './types.js'; + +/** 設定読込前の最小出力処理が外部から受け取る依存関係です。 */ +export type BootstrapConsoleBackendDependencies = { + readonly output: (...args: unknown[]) => void; +}; + +const defaultDependencies: BootstrapConsoleBackendDependencies = { + output: (...args) => console.log(...args), +}; + +/** + * 設定読込前でも利用できる、依存の少ないコンソール出力です。 + * 設定ファイルの読み込み失敗を報告するため、Pretty backendやTelemetryには依存しません。 + */ +export class BootstrapConsoleBackend implements LogBackend { + private readonly dependencies: BootstrapConsoleBackendDependencies; + + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + public write(record: LogRecord): void { + const worker = record.isPrimary ? '*' : record.workerId ?? '?'; + const line = `${record.timestamp} ${record.level.toUpperCase()} ${worker}\t[${record.loggerName}]\t${record.message}`; + const args: unknown[] = [line]; + + if (record.compatibility?.data != null) { + args.push(record.compatibility.data); + } else if (record.eventName != null || record.attributes != null || record.error != null) { + args.push({ + ...(record.eventName != null ? { eventName: record.eventName } : {}), + ...(record.attributes != null ? { attributes: record.attributes } : {}), + ...(record.error != null ? { error: record.error } : {}), + }); + } + + this.dependencies.output(...args); + } + + /** 設定前のAccess logは本文を含めず、最小限の情報だけを出力します。 */ + public writeAccess(record: AccessLogRecord): void { + this.dependencies.output(`${record.timestamp} ACCESS ${record.method} ${record.route ?? '-'} ${record.statusCode}`); + } +} diff --git a/packages/backend/src/logging/JsonConsoleBackend.ts b/packages/backend/src/logging/JsonConsoleBackend.ts new file mode 100644 index 00000000000..fe6f8f0e2be --- /dev/null +++ b/packages/backend/src/logging/JsonConsoleBackend.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { LogBackend } from './LogBackend.js'; +import type { AccessLogRecord, LogRecord } from './types.js'; + +/** JSON形式のログを1行で出力する処理が外部から受け取る依存関係です。 */ +export type JsonConsoleBackendDependencies = { + readonly output: (line: string) => void; +}; + +/** JSON形式で出力する項目を、運用上安定した形として定義します。 */ +type JsonLogRecord = { + readonly timestamp: string; + readonly level: LogRecord['level']; + readonly message: string; + readonly loggerName: string; + readonly eventName?: string; + readonly attributes?: LogRecord['attributes']; + readonly error?: LogRecord['error']; + readonly processId: number; + readonly isPrimary: boolean; + readonly workerId: number | null; + readonly trace_id?: string; + readonly span_id?: string; + 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), +}; + +/** LogRecordからJSONへ出す項目だけを選び、内部情報を誤って含めないようにします。 */ +function createJsonLogRecord(record: LogRecord): JsonLogRecord { + // 色や旧APIの生データは表示専用の情報なので、機械向け形式へは持ち込みません。 + return { + timestamp: record.timestamp, + level: record.level, + message: record.message, + loggerName: record.loggerName, + // 任意項目は値がある場合だけ含め、空の項目を増やさないようにします。 + ...(record.eventName != null ? { eventName: record.eventName } : {}), + ...(record.attributes != null ? { attributes: record.attributes } : {}), + ...(record.error != null ? { error: record.error } : {}), + // 実行主体の情報は常に出し、ログを横断して検索できる形を保ちます。 + processId: record.processId, + isPrimary: record.isPrimary, + workerId: record.workerId, + // active Spanの情報は値が存在するログだけ、標準のsnake_case名で出力します。 + ...(record.traceId != null ? { trace_id: record.traceId } : {}), + ...(record.spanId != null ? { span_id: record.spanId } : {}), + ...(record.traceFlags != null ? { trace_flags: record.traceFlags } : {}), + }; +} + +/** 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形式と同じ記録を共有します。 + */ +export class JsonConsoleBackend implements LogBackend { + private readonly dependencies: JsonConsoleBackendDependencies; + + /** 出力処理を受け取り、テストや起動環境ごとに出力先を差し替えます。 */ + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + /** 1件のログをJSON文字列へ変換し、改行を含まない1回の出力として渡します。 */ + public write(record: LogRecord): void { + // 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))); + } +} diff --git a/packages/backend/src/logging/LogBackend.ts b/packages/backend/src/logging/LogBackend.ts new file mode 100644 index 00000000000..0b79effe7f9 --- /dev/null +++ b/packages/backend/src/logging/LogBackend.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { AccessLogRecord, LogRecord } from './types.js'; + +/** + * 整形済みのログを実際の出力先へ渡すための共通窓口です。 + * Loggerを特定の出力形式へ依存させず、後から出力先を追加できるようにします。 + */ +export interface LogBackend { + /** ログを一件出力します。 */ + write(record: LogRecord): void; + + /** Access logを一件出力します。 */ + writeAccess?(record: AccessLogRecord): void; + + /** 保留中の出力がある場合に、すべて書き出します。 */ + flush?(): void | Promise; + + /** 出力先が持つ資源を解放します。 */ + close?(): void | Promise; +} diff --git a/packages/backend/src/logging/LogManager.ts b/packages/backend/src/logging/LogManager.ts new file mode 100644 index 00000000000..ef78f97bfe2 --- /dev/null +++ b/packages/backend/src/logging/LogManager.ts @@ -0,0 +1,414 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import cluster from 'node:cluster'; +import process from 'node:process'; +import { envOption } from '@/env.js'; +import { + findLegacyLogError, + normalizeLogAttributes, + normalizeLogValue, + serializeLogError, + type LogNormalizationProfile, +} from './LogNormalizer.js'; +import type { LogBackend } from './LogBackend.js'; +import type { + AccessLogConfiguration, + AccessLogRecord, + AccessLogRecordInput, + AccessLogStatusClass, + LogLevel, + LogLevelSetting, + LogRecord, + LogRecordInput, + LogTraceContext, + LogTraceContextProvider, +} from './types.js'; + +/** ログを出力したプロセスを識別するための情報です。 */ +export type LogProcessInfo = { + readonly processId: number; + readonly isPrimary: boolean; + readonly workerId: number | null; +}; + +/** + * 実行環境から取得する値をまとめた依存関係です。 + * テストでは固定値へ差し替え、時刻やプロセス状態に左右されないようにします。 + */ +export type LogManagerDependencies = { + readonly now: () => Date; + readonly getProcessInfo: () => LogProcessInfo; + readonly isQuiet: () => boolean; + readonly isVerbose: () => boolean; + readonly getNodeEnv: () => string | undefined; +}; + +/** ログ管理の初期化時に指定できる正規化設定です。 */ +export type LogManagerOptions = { + readonly normalizationProfile?: LogNormalizationProfile; +}; + +/** 起動時に適用するログ出力設定です。 */ +export type LogManagerConfiguration = { + readonly level?: LogLevelSetting; + readonly domains?: Readonly> | 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> = { + debug: 0, + info: 1, + warn: 2, + error: 3, + fatal: 4, +}; + +const validLogLevels = new Set(['debug', 'info', 'warn', 'error', 'fatal', 'off']); +const validAccessStatusClasses = new Set(['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; + if (typeof value !== 'string' || !validLogLevels.has(value as LogLevelSetting)) { + throw new Error(`${path} must be one of debug, info, warn, error, fatal, or off`); + } + return value as LogLevelSetting; +} + +function validateDomainName(domain: string): void { + if (domain.length === 0 || domain.trim() !== domain || domain.split('.').some(segment => segment.length === 0)) { + throw new Error(`logging.domains contains an invalid domain name: ${JSON.stringify(domain)}`); + } +} + +/** 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: [], access: createDisabledAccessLogConfiguration(), warnings: [] }; + + const level = validateLogLevel(configuration.level, 'logging.level'); + 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'); + } + + const domains = Object.entries(configuration.domains).map(([domain, value]) => { + validateDomainName(domain); + const level = validateLogLevel(value, `logging.domains.${domain}`); + if (typeof level === 'undefined') { + throw new Error(`logging.domains.${domain} must be configured`); + } + return [domain, level] as const; + }).sort((left, right) => right[0].length - left[0].length); + + return { level, domains, ...access }; +} + +const defaultDependencies: LogManagerDependencies = { + now: () => new Date(), + getProcessInfo: () => ({ + processId: process.pid, + isPrimary: cluster.isPrimary, + workerId: cluster.isPrimary ? null : (cluster.worker?.id ?? null), + }), + isQuiet: () => envOption.quiet, + isVerbose: () => envOption.verbose, + getNodeEnv: () => process.env.NODE_ENV, +}; + +/** + * ログの出力可否を判断し、すべての出力先で共通となる情報を付加します。 + * Loggerと出力先の間に置くことで、設定や共通情報の扱いを一か所へ集約します。 + */ +export class LogManager { + private backend: LogBackend; + private readonly dependencies: LogManagerDependencies; + private normalizationProfile: LogNormalizationProfile; + private traceContextProvider: LogTraceContextProvider | undefined; + private configuredLevel: LogLevelSetting | undefined; + private configuredDomains: readonly (readonly [string, LogLevelSetting])[]; + private accessConfiguration: ResolvedAccessLogConfiguration; + private shutdownPromise: Promise | undefined; + + /** + * 出力先と実行環境から値を取得する処理を受け取ります。 + * 実行環境の取得処理は、必要な項目だけテスト用に差し替えられます。 + */ + constructor( + backend: LogBackend, + dependencies: Partial = {}, + options: LogManagerOptions = {}, + ) { + this.backend = backend; + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + this.normalizationProfile = options.normalizationProfile ?? 'standard'; + this.traceContextProvider = undefined; + this.configuredLevel = undefined; + this.configuredDomains = []; + this.accessConfiguration = createDisabledAccessLogConfiguration(); + } + + /** + * 以後のログを書き込む出力先を切り替えます。 + * 作成済みのLoggerにも切り替えを反映するため、LogManager側で保持します。 + */ + public setBackend(backend: LogBackend): void { + this.backend = backend; + } + + /** 起動時の既定levelとdomain別levelを適用します。 */ + 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にも反映します。 */ + public setNormalizationProfile(profile: LogNormalizationProfile): void { + this.normalizationProfile = profile; + } + + /** ログ出力時にactiveなTrace Contextを取得する処理を登録します。 */ + public setTraceContextProvider(provider?: LogTraceContextProvider): void { + this.traceContextProvider = provider; + } + + /** 現在の処理に紐付くTrace Contextを取得します。 */ + public getActiveTraceContext(): LogTraceContext | undefined { + return this.traceContextProvider?.(); + } + + /** backendに残っているログをflushしてから終了処理を行います。 */ + public shutdown(): Promise { + if (this.shutdownPromise != null) return this.shutdownPromise; + + this.shutdownPromise = (async () => { + try { + await this.backend.flush?.(); + } finally { + await this.backend.close?.(); + } + })(); + + return this.shutdownPromise; + } + + private getDefaultLevel(): LogLevel { + if (this.dependencies.isVerbose()) return 'debug'; + return this.dependencies.getNodeEnv() === 'production' ? 'info' : 'debug'; + } + + private getThreshold(loggerName: string): LogLevelSetting { + let threshold: LogLevelSetting | undefined; + for (const [domain, level] of this.configuredDomains) { + if (loggerName === domain || loggerName.startsWith(`${domain}.`)) { + threshold = level; + break; + } + } + + threshold ??= this.configuredLevel ?? this.getDefaultLevel(); + + // verboseは障害調査用の緊急モードとして、明示されたoff以外をdebugまで下げる。 + // offは意図的な無効化なので、verboseでも再有効化しない。 + return threshold === 'off' || !this.dependencies.isVerbose() ? threshold : 'debug'; + } + + private shouldWrite(input: LogRecordInput, loggerName: string): boolean { + const threshold = this.getThreshold(loggerName); + if (threshold === 'off') return false; + return logLevelOrder[input.level] >= logLevelOrder[threshold]; + } + + /** + * 出力条件を確認し、共通情報を付加して出力先へ渡します。 + */ + public write(input: LogRecordInput): void { + // `quiet`は他の条件より優先し、ログに付随する情報の取得も行いません。 + if (this.dependencies.isQuiet()) return; + + const loggerName = input.context.map(segment => segment.name).join('.'); + if (!this.shouldWrite(input, loggerName)) return; + + const processInfo = this.dependencies.getProcessInfo(); + // 呼び出し側の配列を共有せず、親から末端までの順序を固定します。 + const context = [...input.context]; + // 出力を実際に行う直前にだけ正規化し、捨てられるdebugログのコストを抑えます。 + const { attributes, error: inputError, ...inputWithoutStructuredValues } = input; + const normalizedAttributes = typeof attributes !== 'undefined' + ? normalizeLogAttributes(attributes, { profile: this.normalizationProfile }) + : undefined; + const error = inputError ?? findLegacyLogError(input.compatibility?.data); + const normalizedError = typeof error !== 'undefined' + ? serializeLogError(error, { profile: this.normalizationProfile }) + : undefined; + // 実際に出力するログだけ、TelemetryからactiveなTrace Contextを取得します。 + const traceContext = this.traceContextProvider?.(); + const record = { + ...inputWithoutStructuredValues, + context, + timestamp: this.dependencies.now().toISOString(), + loggerName, + processId: processInfo.processId, + isPrimary: processInfo.isPrimary, + workerId: processInfo.workerId, + ...(traceContext ?? {}), + ...(normalizedAttributes ? { attributes: normalizedAttributes } : {}), + ...(normalizedError ? { error: normalizedError } : {}), + } as LogRecord; + + 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); + } +} diff --git a/packages/backend/src/logging/LogNormalizer.ts b/packages/backend/src/logging/LogNormalizer.ts new file mode 100644 index 00000000000..117f03b7ba9 --- /dev/null +++ b/packages/backend/src/logging/LogNormalizer.ts @@ -0,0 +1,400 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Buffer } from 'node:buffer'; +import type { LogAttributeValue, LogAttributes, SerializedError } from './types.js'; + +/** 正規化の粒度を表します。詳細指定でも秘匿処理は常に有効です。 */ +export type LogNormalizationProfile = 'standard' | 'detailed'; + +/** 正規化で使う上限値です。 */ +export type LogNormalizationLimits = { + readonly maxDepth: number; + readonly maxEntries: number; + readonly maxStringBytes: number; + readonly maxBytes: number; +}; + +/** 属性のキーを秘匿すべきか判定する関数です。 */ +export type LogRedactor = (path: readonly string[], key: string) => boolean; + +/** 正規化処理へ渡す設定です。 */ +export type LogNormalizationOptions = { + readonly profile?: LogNormalizationProfile; + readonly limits?: Partial; + readonly redactor?: LogRedactor; +}; + +/** 通常運用でログが肥大化しないようにした上限です。 */ +export const STANDARD_LOG_NORMALIZATION_LIMITS: LogNormalizationLimits = { + maxDepth: 6, + maxEntries: 100, + maxStringBytes: 8 * 1024, + maxBytes: 64 * 1024, +}; + +/** 障害調査時により多くの情報を残す上限です。 */ +export const DETAILED_LOG_NORMALIZATION_LIMITS: LogNormalizationLimits = { + maxDepth: 10, + maxEntries: 1000, + maxStringBytes: 64 * 1024, + maxBytes: 256 * 1024, +}; + +const REDACTED = '[REDACTED]'; +const CIRCULAR = '[Circular]'; +const TRUNCATED = '[Truncated]'; +const UNSUPPORTED = '[Unsupported]'; +const TRUNCATED_KEY = '[Truncated]'; + +const sensitiveKeyParts = [ + 'password', + 'passwd', + 'passphrase', + 'token', + 'secret', + 'authorization', + 'cookie', + 'apikey', + 'privatekey', + 'credential', + 'captcha', + 'hcaptcharesponse', + 'grecaptcharesponse', + 'turnstileresponse', + 'mcaptcharesponse', + 'testcaptcharesponse', +]; + +/** キー名を比較用に揃え、区切り文字による表記揺れを吸収します。 */ +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[-_.\s]/g, ''); +} + +/** 既定の秘匿対象を判定します。Misskey APIの`i`も認証情報として扱います。 */ +export function defaultLogRedactor(_path: readonly string[], key: string): boolean { + const normalized = normalizeKey(key); + return normalized === 'i' || sensitiveKeyParts.some(part => normalized.includes(part)); +} + +/** 選択した方式と個別指定を合わせて、実際の上限値を決めます。 */ +export function resolveLogNormalizationLimits(options: LogNormalizationOptions = {}): LogNormalizationLimits { + const base = options.profile === 'detailed' + ? DETAILED_LOG_NORMALIZATION_LIMITS + : STANDARD_LOG_NORMALIZATION_LIMITS; + const limits = { + ...base, + ...options.limits, + }; + return { + maxDepth: Math.max(0, limits.maxDepth), + maxEntries: Math.max(0, limits.maxEntries), + maxStringBytes: Math.max(1, limits.maxStringBytes), + maxBytes: Math.max(2, limits.maxBytes), + }; +} + +/** 値が通常のオブジェクトとして読めるか判定します。 */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** 外部入力のキーを安全に格納するため、prototypeを持たない属性領域を作成します。 */ +function createAttributeMap(): Record { + return Object.create(null) as Record; +} + +/** 値を文字列化し、文字列化処理自体の例外もログ処理へ漏らさないようにします。 */ +function stringifySafely(value: unknown): string { + try { + return String(value); + } catch { + return UNSUPPORTED; + } +} + +/** UTF-8のバイト数を測ります。ログの上限を文字数ではなく出力サイズで揃えるために使います。 */ +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +/** 文字列をUTF-8の上限内へ切り詰めます。 */ +function normalizeString(value: string, maxBytes: number): string { + if (byteLength(value) <= maxBytes) return value; + const suffix = `…${TRUNCATED}`; + if (byteLength(suffix) > maxBytes) { + const end = findMaxPrefixLength(value, '', maxBytes); + return value.slice(0, end); + } + const end = findMaxPrefixLength(value, suffix, maxBytes); + return value.slice(0, end) + suffix; +} + +function charUtf8Len(codePoint: number): number { + return codePoint < 0x80 ? 1 + : codePoint < 0x800 ? 2 + : codePoint < 0x10000 ? 3 + : 4; +} + +function charUtf16Len(codePoint: number): number { + return codePoint < 0x10000 ? 1 : 2; +} + +function findMaxPrefixLength(value: string, suffix: string, maxBytes: number) { + let usedBytes = byteLength(suffix); + let prefixLength = 0; + while (prefixLength < value.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- similar to for-of, but for-of requires additional allocations + const cp = value.codePointAt(prefixLength)!; + const charBytes = charUtf8Len(cp); + if (usedBytes + charBytes > maxBytes) break; + usedBytes += charBytes; + prefixLength += charUtf16Len(cp); + } + return prefixLength; +} + +/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */ +function isErrorValue(value: unknown): value is Error { + try { + return value instanceof Error; + } catch { + return false; + } +} + +/** 値の読み出しを安全に行い、壊れたログ属性が本処理を中断しないようにします。 */ +function readProperty(value: Record, key: string): unknown { + try { + return value[key]; + } catch { + return `${UNSUPPORTED}: property access failed`; + } +} + +/** 属性をJSONへ出力できる値へ変換します。 */ +function normalizeValue( + value: unknown, + path: readonly string[], + depth: number, + seen: WeakSet, + limits: LogNormalizationLimits, + redactor: LogRedactor, +): LogAttributeValue { + if (value === null) return null; + if (typeof value === 'string') return normalizeString(value, limits.maxStringBytes); + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : stringifySafely(value); + if (typeof value === 'bigint') return value.toString(10); + if (typeof value === 'undefined') return `${UNSUPPORTED}: undefined`; + if (typeof value === 'function' || typeof value === 'symbol') return `${UNSUPPORTED}: ${typeof value}`; + let isArray = false; + try { + if (value instanceof Date) { + return normalizeString(value.toISOString(), limits.maxStringBytes); + } + isArray = Array.isArray(value); + if (!isArray) { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${UNSUPPORTED}: object`; + } + } catch { + return `${UNSUPPORTED}: object access failed`; + } + if (seen.has(value)) return CIRCULAR; + if (depth >= limits.maxDepth) return TRUNCATED; + seen.add(value); + + try { + if (isArray) { + const arrayValue = value as readonly unknown[]; + const result: LogAttributeValue[] = []; + const entries = Math.min(arrayValue.length, limits.maxEntries); + for (let i = 0; i < entries; i++) { + result.push(normalizeValue(arrayValue[i], [...path, String(i)], depth + 1, seen, limits, redactor)); + } + if (arrayValue.length > entries) result.push(TRUNCATED); + return result; + } + + // 攻撃者が指定した`__proto__`を通常の属性として保持するため、null prototypeを使います。 + const result = createAttributeMap(); + const keys = Object.keys(value).sort(); + const entries = Math.min(keys.length, limits.maxEntries); + for (let i = 0; i < entries; i++) { + const key = keys[i]; + // 秘匿対象は値を読み出す前に置き換え、読み出し処理や巨大な値にも触れません。 + result[key] = redactor(path, key) + ? REDACTED + : normalizeValue(readProperty(value as Record, key), [...path, key], depth + 1, seen, limits, redactor); + } + if (keys.length > entries) result[TRUNCATED_KEY] = TRUNCATED; + return result; + } catch { + return `${UNSUPPORTED}: object access failed`; + } finally { + seen.delete(value); + } +} + +/** JSON文字列化した値のバイト数を測ります。正規化後の最終上限に使います。 */ +function serializedByteLength(value: LogAttributeValue): number { + return byteLength(JSON.stringify(value)); +} + +/** 正規化済みの値を上限内へ再帰的に縮めます。 */ +function trimToByteLimit(value: LogAttributeValue, maxBytes: number): LogAttributeValue { + if (serializedByteLength(value) <= maxBytes) return value; + if (typeof value === 'string') return normalizeString(value, maxBytes); + if (Array.isArray(value)) { + const result: LogAttributeValue[] = []; + for (const item of value) { + const trimmedItem = trimToByteLimit(item, maxBytes); + const candidate = [...result, trimmedItem]; + if (serializedByteLength(candidate) > maxBytes) break; + result.push(trimmedItem); + } + if (result.length < value.length && serializedByteLength([...result, TRUNCATED]) <= maxBytes) result.push(TRUNCATED); + return result; + } + if (isObject(value)) { + // 上限調整中も`__proto__`を安全に属性として扱えるようにします。 + const result = createAttributeMap(); + for (const key of Object.keys(value).sort()) { + const candidate = Object.assign(createAttributeMap(), result, { [key]: trimToByteLimit(value[key], maxBytes) }); + if (serializedByteLength(candidate) > maxBytes) break; + result[key] = candidate[key]; + } + if (Object.keys(result).length < Object.keys(value).length) { + const candidate = Object.assign(createAttributeMap(), result, { [TRUNCATED_KEY]: TRUNCATED }); + if (serializedByteLength(candidate) <= maxBytes) return candidate; + } + return result; + } + return TRUNCATED; +} + +/** エラーらしい原因情報かを安全に判定します。特殊な値もログ処理を壊しません。 */ +function isErrorLike(value: unknown): value is Record { + if (!isObject(value)) return false; + try { + return ['name', 'message', 'stack', 'cause'].some(key => key in value); + } catch { + return false; + } +} + +/** 属性をJSONへ出力できるオブジェクトへ正規化します。 */ +export function normalizeLogAttributes(value: unknown, options: LogNormalizationOptions = {}): LogAttributes { + const limits = resolveLogNormalizationLimits(options); + const redactor = options.redactor ?? defaultLogRedactor; + const normalized = normalizeValue(value, [], 0, new WeakSet(), limits, redactor); + const root = isObject(normalized) && !Array.isArray(normalized) ? normalized : { value: normalized }; + 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(), limits, redactor); + return trimToByteLimit(normalized, limits.maxBytes); +} + +/** 旧APIのdata領域から、エラー本体を見つけて構造化した形へ渡します。 */ +export function findLegacyLogError(value: unknown): unknown { + if (isErrorValue(value)) return value; + if (!isObject(value)) return undefined; + for (const key of ['e', 'err', 'error', 'stack']) { + const candidate = readProperty(value, key); + if (isErrorValue(candidate)) return candidate; + } + return undefined; +} + +/** エラーの原因情報を含めて、一定の形へ正規化します。 */ +function serializeErrorValue( + value: unknown, + depth: number, + seen: WeakSet, + limits: LogNormalizationLimits, + redactor: LogRedactor, +): SerializedError { + if (!isObject(value)) { + return { + type: value === null ? 'null' : typeof value, + message: normalizeString(stringifySafely(value), limits.maxStringBytes), + }; + } + if (seen.has(value)) return { type: 'CircularError', message: CIRCULAR }; + seen.add(value); + + try { + const name = readProperty(value, 'name'); + const message = readProperty(value, 'message'); + const stack = readProperty(value, 'stack'); + const cause = readProperty(value, 'cause'); + const constructor = readProperty(value, 'constructor'); + const constructorName = typeof constructor === 'function' || isObject(constructor) + ? readProperty(constructor as Record, 'name') + : undefined; + const type = normalizeString( + typeof name === 'string' && name.length > 0 + ? name + : typeof constructorName === 'string' && constructorName.length > 0 ? constructorName : 'Error', + limits.maxStringBytes, + ); + const result: { type: string; message: string; stack?: string; cause?: SerializedError | LogAttributeValue } = { + type, + message: normalizeString(typeof message === 'string' ? message : stringifySafely(value), limits.maxStringBytes), + }; + if (typeof stack === 'string') result.stack = normalizeString(stack, limits.maxStringBytes); + if (typeof cause !== 'undefined') { + result.cause = depth < limits.maxDepth + ? isErrorValue(cause) || isErrorLike(cause) + ? serializeErrorValue(cause, depth + 1, seen, limits, redactor) + : normalizeValue(cause, ['cause'], depth + 1, seen, limits, redactor) + : TRUNCATED; + } + return result; + } finally { + seen.delete(value); + } +} + +/** エラーの必須項目を残したまま、全体の出力サイズを上限内へ縮めます。 */ +function trimSerializedError(value: SerializedError, maxBytes: number): SerializedError { + const requiredLimit = Math.max(0, Math.floor((maxBytes - 24) / 2)); + const result: { type: string; message: string; stack?: string; cause?: SerializedError | LogAttributeValue } = { + type: normalizeString(value.type, requiredLimit), + message: normalizeString(value.message, requiredLimit), + }; + if (serializedByteLength(result as unknown as LogAttributeValue) > maxBytes) { + return { type: '', message: '' }; + } + if (value.stack != null) { + const candidate = { ...result, stack: value.stack }; + if (serializedByteLength(candidate as unknown as LogAttributeValue) <= maxBytes) result.stack = value.stack; + } + if (value.cause != null) { + const cause = typeof value.cause === 'object' + ? trimToByteLimit(value.cause as LogAttributeValue, maxBytes) + : value.cause; + const candidate = { ...result, cause }; + if (serializedByteLength(candidate as unknown as LogAttributeValue) <= maxBytes) result.cause = cause as SerializedError | LogAttributeValue; + } + return result; +} + +/** 任意のエラー入力を、ログへ安全に埋め込める形へ変換します。 */ +export function serializeLogError(value: unknown, options: LogNormalizationOptions = {}): SerializedError | undefined { + if (typeof value === 'undefined' || value === null) return undefined; + const limits = resolveLogNormalizationLimits(options); + // 必須項目を含む最小のJSON形すら収まらない場合は、上限を超えないよう出力しません。 + if (limits.maxBytes < 24) return undefined; + const serialized = serializeErrorValue(value, 0, new WeakSet(), limits, options.redactor ?? defaultLogRedactor); + return trimSerializedError(serialized, limits.maxBytes); +} diff --git a/packages/backend/src/logging/PrettyConsoleBackend.ts b/packages/backend/src/logging/PrettyConsoleBackend.ts new file mode 100644 index 00000000000..7a5d277bcda --- /dev/null +++ b/packages/backend/src/logging/PrettyConsoleBackend.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import chalk from 'chalk'; +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 { AccessLogRecord, LogAttributeValue, LogRecord } from './types.js'; + +/** 見やすい形式の出力処理が外部から受け取る依存関係です。 */ +export type PrettyConsoleBackendDependencies = { + readonly output: (...args: unknown[]) => void; + readonly withLogTime: () => boolean; +}; + +const defaultDependencies: PrettyConsoleBackendDependencies = { + output: (...args) => console.log(...args), + 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へ任せます。 + */ +export class PrettyConsoleBackend implements LogBackend { + private readonly dependencies: PrettyConsoleBackendDependencies; + + /** + * 出力処理と時刻表示の判定処理を受け取ります。 + * 省略時は従来どおり、標準のコンソールと環境設定を使用します。 + */ + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...defaultDependencies, + ...dependencies, + }; + } + + /** + * 共通のログを従来形式へ整形して一件出力します。 + */ + public write(record: LogRecord): void { + const legacyLevel = record.compatibility?.legacyLevel; + // `fatal`は重大なエラーとして扱い、従来の`important`と同じ強調表示にします。 + const important = record.level === 'fatal' || (record.compatibility?.important ?? false); + const presentationLevel = record.level === 'fatal' ? 'error' : (legacyLevel ?? record.level); + const label = + presentationLevel === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : + presentationLevel === 'warn' ? chalk.yellow('WARN') : + presentationLevel === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : + presentationLevel === 'debug' ? chalk.gray('VERB') : + presentationLevel === 'info' ? chalk.blue('INFO') : + null; + const contexts = record.context.map(context => context.color + ? chalk.rgb(...convertColor.keyword.rgb(context.color))(context.name) + : chalk.white(context.name)); + const message = + presentationLevel === 'error' ? chalk.red(record.message) : + presentationLevel === 'warn' ? chalk.yellow(record.message) : + presentationLevel === 'success' ? chalk.green(record.message) : + presentationLevel === 'debug' ? chalk.gray(record.message) : + presentationLevel === 'info' ? record.message : + null; + // 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。 + const worker = record.isPrimary ? '*' : record.workerId; + + let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`; + if (this.dependencies.withLogTime()) { + log = chalk.gray(dateFormat(new Date(record.timestamp), 'HH:mm:ss')) + ' ' + log; + } + + // `data`は文字列へ埋め込まず、第2引数として渡す従来の挙動を維持します。 + const args: unknown[] = [important ? chalk.bold(log) : log]; + if (record.compatibility?.data != null) { + // 旧形式の値はそのまま第2引数へ渡し、既存の表示と調査方法を保ちます。 + args.push(record.compatibility.data); + } else if (record.eventName != null || record.attributes != null || record.error != null) { + // 構造化ログは、専用の出力先がなくても調査情報を確認できるようにします。 + args.push({ + ...(record.eventName != null ? { eventName: record.eventName } : {}), + ...(record.attributes != null ? { attributes: record.attributes } : {}), + ...(record.error != null ? { error: record.error } : {}), + }); + } + 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); + } +} diff --git a/packages/backend/src/logging/logging-runtime.ts b/packages/backend/src/logging/logging-runtime.ts new file mode 100644 index 00000000000..223f6e379ee --- /dev/null +++ b/packages/backend/src/logging/logging-runtime.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { LogManager } from './LogManager.js'; +import { BootstrapConsoleBackend } from './BootstrapConsoleBackend.js'; +import { JsonConsoleBackend } from './JsonConsoleBackend.js'; +import { PrettyConsoleBackend } from './PrettyConsoleBackend.js'; +import type { LogManagerConfiguration } from './LogManager.js'; +import type { LogTraceContextProvider, LogFormat } from './types.js'; +import type { LogBackend } from './LogBackend.js'; + +/** + * プロセス内のすべてのLoggerが共有するLogManagerです。 + * Logger作成後も同じLogManagerを参照するため、出力先の切り替えを一括で反映できます。 + */ +export const logManager = new LogManager(new BootstrapConsoleBackend()); + +/** ログ形式を検証し、指定された形式に対応する出力処理を作成します。 */ +function createLoggingBackend(format: unknown): LogBackend { + if (format == null || format === 'pretty') return new PrettyConsoleBackend(); + if (format === 'json') return new JsonConsoleBackend(); + throw new Error('logging.format must be either pretty or json'); +} + +/** 起動時のログ設定を適用し、選択した出力処理へ切り替えます。 */ +export function configureLogging(configuration?: LogManagerConfiguration & { readonly format?: LogFormat }): void { + // 出力処理を先に検証し、設定値が不正な場合は現在の出力処理を壊さないようにします。 + const backend = createLoggingBackend(configuration?.format); + const warnings = logManager.configure(configuration); + logManager.setBackend(backend); + // 設定を無視した理由を、選択済みの形式で起動時に一度だけ知らせます。 + for (const message of warnings) { + logManager.write({ + level: 'warn', + message, + context: [{ name: 'logging' }], + }); + } +} + +/** Telemetry初期化後に、ログへTrace Contextを付加する取得処理を登録します。 */ +export function setLogTraceContextProvider(provider?: LogTraceContextProvider): void { + logManager.setTraceContextProvider(provider); +} + +/** プロセス終了前に現在のログ出力処理を保留分まで書き出して閉じます。 */ +export function shutdownLogging(): Promise { + return logManager.shutdown(); +} diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts new file mode 100644 index 00000000000..ddd10f410f8 --- /dev/null +++ b/packages/backend/src/logging/types.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Keyword } from 'color-convert'; + +/** ログの重要度を表します。 */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +/** 設定で指定できるログの閾値です。`off`はログイベントのlevelには使用しません。 */ +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 + | number + | boolean + | null + | readonly LogAttributeValue[] + | { readonly [key: string]: LogAttributeValue }; + +/** 正規化後のログ属性です。 */ +export type LogAttributes = Readonly>; + +/** activeなSpanとログを関連付けるためのTrace Contextです。 */ +export type LogTraceContext = { + readonly traceId: string; + readonly spanId: string; + readonly traceFlags: number; +}; + +/** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */ +export type LogTraceContextProvider = () => LogTraceContext | undefined; + +/** level別のLoggerメソッドが受け取る構造化ログの共通入力です。 */ +export type LogEntryInput = { + readonly message: string; + readonly eventName?: string; + readonly attributes?: Readonly>; + readonly error?: unknown; +}; + +/** 動的なlevelを含めてLogManagerへ渡す構造化ログの入力です。 */ +export type LogWriteInput = LogEntryInput & { + readonly level: LogLevel; +}; + +/** + * ロガー名を構成する一要素です。 + * 色は見やすい形式での表示だけに使い、ログの意味には影響させません。 + */ +export type LoggerContext = { + readonly name: string; + readonly color?: Keyword; +}; + +/** + * 従来のコンソール表示を維持するための情報です。 + * 構造化ログの項目と混同しないよう、互換用の領域へ分離しています。 + * `data`は従来表示を保つため正規化せず、秘匿が必要な値は構造化属性へ移します。 + */ +export type LogCompatibility = { + readonly legacyLevel?: 'success'; + readonly important?: boolean; + readonly data?: unknown; +}; + +/** + * 呼び出し側からLogManagerへ渡す、時刻などを付加する前のログです。 + */ +export type LogRecordInput = LogWriteInput & { + readonly context: readonly LoggerContext[]; + readonly compatibility?: LogCompatibility; +}; + +/** エラーをJSONへ出力するために正規化した形です。 */ +export type SerializedError = { + readonly type: string; + readonly message: string; + readonly stack?: string; + readonly cause?: SerializedError | LogAttributeValue; +}; + +/** + * 出力先へ渡すログです。 + * `compatibility.data`は見やすい形式だけが使う従来値で、構造化した出力先は属性とエラーを利用します。 + */ +export type LogRecord = Omit & { + readonly timestamp: string; + readonly loggerName: string; + readonly processId: number; + readonly isPrimary: boolean; + readonly workerId: number | null; + readonly traceId?: string; + readonly spanId?: string; + readonly traceFlags?: number; + 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 & { + 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; +}; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f1eae582a89..936b049f006 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -204,14 +204,13 @@ export class RedisSingleCache { } } -// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? - export class MemoryKVCache { private readonly cache = new Map(); private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m constructor( private readonly lifetime: number, + private readonly limit: number = Infinity, ) {} @bindThis @@ -220,6 +219,25 @@ export class MemoryKVCache { * @deprecated これを直接呼び出すべきではない。InternalEventなどで変更を全てのプロセス/マシンに通知するべき */ public set(key: string, value: T): void { + if (this.limit <= 0) { + throw new Error('Limit must be greater than 0'); + } + + if (this.limit !== Infinity) { + this.gc(); + + // 挿入順を更新して LRU を保つため、同一キーは一度削除する + this.cache.delete(key); + + while (this.cache.size >= this.limit) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey == null) { + throw new Error('Cache is empty but size exceeds the limit'); + } + this.cache.delete(oldestKey); + } + } + this.cache.set(key, { date: Date.now(), value, @@ -234,6 +252,11 @@ export class MemoryKVCache { this.cache.delete(key); return undefined; } + if (this.limit !== Infinity) { + // access 順を更新して LRU を保つ + this.cache.delete(key); + this.cache.set(key, cached); + } return cached.value; } diff --git a/packages/backend/src/models/Hashtag.ts b/packages/backend/src/models/Hashtag.ts index 3add06d0c38..43013489573 100644 --- a/packages/backend/src/models/Hashtag.ts +++ b/packages/backend/src/models/Hashtag.ts @@ -20,6 +20,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public mentionedUserIds: MiUser['id'][]; @@ -32,6 +33,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public mentionedLocalUserIds: MiUser['id'][]; @@ -44,6 +46,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public mentionedRemoteUserIds: MiUser['id'][]; @@ -56,6 +59,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public attachedUserIds: MiUser['id'][]; @@ -68,6 +72,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public attachedLocalUserIds: MiUser['id'][]; @@ -80,6 +85,7 @@ export class MiHashtag { @Column({ ...id(), + default: [], array: true, }) public attachedRemoteUserIds: MiUser['id'][]; diff --git a/packages/backend/src/models/InboxRule.ts b/packages/backend/src/models/InboxRule.ts index 4aeb5f96988..fee98b2aee7 100644 --- a/packages/backend/src/models/InboxRule.ts +++ b/packages/backend/src/models/InboxRule.ts @@ -152,28 +152,28 @@ type CondFormulaValueServerIsSilenced = { }; type CondFormulaValueServerPubLessThanOrEq = { - type : 'serverPubLessThanOrEq' + type: 'serverPubLessThanOrEq' value: number; -} +}; type CondFormulaValueServerPubMoreThanOrEq = { - type : 'serverPubMoreThanOrEq' + type: 'serverPubMoreThanOrEq' value: number; -} +}; type CondFormulaValueServerSubLessThanOrEq = { - type : 'serverSubLessThanOrEq' + type: 'serverSubLessThanOrEq' value: number; -} +}; type CondFormulaValueServerSubMoreThanOrEq = { - type : 'serverSubMoreThanOrEq' + type: 'serverSubMoreThanOrEq' value: number; -} +}; type CondFormulaThisActivityIsNote = { - type : 'thisActivityIsNote' -} + type: 'thisActivityIsNote' +}; export type InboxRuleCondFormulaValue = { id: string } & ( CondFormulaValueAnd | @@ -207,7 +207,7 @@ export type InboxRuleCondFormulaValue = { id: string } & ( export type InboxRuleAction = { type: 'reject' | 'messageRewrite'; rewrite?: string | null | undefined; -} +}; import { PrimaryColumn, Entity, Column } from 'typeorm'; import { id } from './util/id.js'; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index b53a70fc838..602d4c5ec68 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -92,7 +92,6 @@ import { import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; - const $inboxRuleRepository: Provider = { provide: DI.inboxRuleRepository, useFactory: (db: DataSource) => db.getRepository(MiInboxRule), diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index db37719be93..5bb8155d6ab 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -11,6 +11,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; +import { runQueueJob } from './queue-job-runner.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -160,6 +161,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; } + // 以下の各 Worker はジョブの実処理全体を worker span で囲む。 //#region system { const processer = (job: Bull.Job) => { @@ -177,26 +179,29 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for system`); } }; + const logger = this.logger.createSubLogger('system'); this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job)); + return runQueueJob( + this.telemetryService, + 'Queue: System: ' + job.name, + () => processer(job) as Promise, + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: System: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, }); - const logger = this.logger.createSubLogger('system'); - this.systemQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err: Error) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -228,26 +233,29 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for db`); } }; + const logger = this.logger.createSubLogger('db'); this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job)); + return runQueueJob( + this.telemetryService, + 'Queue: DB: ' + job.name, + () => processer(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: DB: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, }); - const logger = this.logger.createSubLogger('db'); - this.dbQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -255,8 +263,21 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region deliver { + const logger = this.logger.createSubLogger('deliver'); + this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { - return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job)); + return runQueueJob( + this.telemetryService, + 'Queue: Deliver', + () => this.deliverProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, @@ -270,18 +291,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('deliver'); - this.deliverQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -289,8 +301,22 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region inbox { + const logger = this.logger.createSubLogger('inbox'); + this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { - return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job)); + return runQueueJob( + this.telemetryService, + 'Queue: Inbox', + () => this.inboxProcessorService.process(job), + err => { + const activityId = job.data.activity ? job.data.activity.id : 'none'; + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${activityId}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, @@ -304,18 +330,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('inbox'); - this.inboxQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -323,8 +340,21 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region user-webhook deliver { + const logger = this.logger.createSubLogger('user-webhook'); + this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { - return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job)); + return runQueueJob( + this.telemetryService, + 'Queue: UserWebhookDeliver', + () => this.userWebhookDeliverProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, @@ -338,18 +368,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('user-webhook'); - this.userWebhookDeliverQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -357,8 +378,21 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system-webhook deliver { + const logger = this.logger.createSubLogger('system-webhook'); + this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { - return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job)); + return runQueueJob( + this.telemetryService, + 'Queue: SystemWebhookDeliver', + () => this.systemWebhookDeliverProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, @@ -372,18 +406,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('system-webhook'); - this.systemWebhookDeliverQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -400,9 +425,21 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for relationship`); } }; + const logger = this.logger.createSubLogger('relationship'); this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job)); + return runQueueJob( + this.telemetryService, + 'Queue: Relationship: ' + job.name, + () => processer(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: Relationship: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, @@ -413,18 +450,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('relationship'); - this.relationshipQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -439,27 +467,30 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for objectStorage`); } }; + const logger = this.logger.createSubLogger('objectStorage'); this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job)); + return runQueueJob( + this.telemetryService, + 'Queue: ObjectStorage: ' + job.name, + () => processer(job) as Promise, + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, concurrency: 16, }); - const logger = this.logger.createSubLogger('objectStorage'); - this.objectStorageQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -467,8 +498,21 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region ended poll notification { + const logger = this.logger.createSubLogger('ended-poll-notification'); + this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { - return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job)); + return runQueueJob( + this.telemetryService, + 'Queue: EndedPollNotification', + () => this.endedPollNotificationProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: EndedPollNotification: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, @@ -478,8 +522,21 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region post scheduled note { - this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { - return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job)); + const logger = this.logger.createSubLogger('post-scheduled-note'); + + this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => { + return runQueueJob( + this.telemetryService, + 'Queue: PostScheduledNote', + () => this.postScheduledNoteProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: PostScheduledNote: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE), autorun: false, diff --git a/packages/backend/src/queue/queue-job-runner.ts b/packages/backend/src/queue/queue-job-runner.ts new file mode 100644 index 00000000000..72c9957a8e3 --- /dev/null +++ b/packages/backend/src/queue/queue-job-runner.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { TelemetryService } from '@/core/telemetry/TelemetryService.js'; + +type QueueTelemetryService = Pick; + +/** Queueのprocessorを実行し、失敗処理をSpan内で行います。 */ +export function runQueueJob( + telemetryService: QueueTelemetryService, + spanName: string, + processJob: () => T | Promise, + onError: (error: Error) => void, +): Promise { + return telemetryService.startSpan(spanName, async (): Promise => { + try { + return await processJob(); + } catch (error) { + // 失敗イベントを待たず、processor Spanがactiveな間にログと通知を行います。 + const normalizedError = error instanceof Error ? error : new Error(String(error)); + try { + onError(normalizedError); + } catch { + // 失敗ログの処理が例外を投げても、Queueへは元のエラーを返します。 + } + throw error; + } + }); +} diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 4f36b2a2071..44620f965b5 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -6,14 +6,14 @@ import cluster from 'node:cluster'; import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { envOption } from '@/env.js'; -import { RoleService } from '@/core/RoleService.js'; -import { SignupService } from '@/core/SignupService.js'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; +import { SignupService } from '@/core/SignupService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { envOption } from '@/env.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; import type { EmojisRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js'; @@ -34,6 +34,7 @@ import { HealthServerService } from './HealthServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; +import { registerHttpAccessLog } from './http-access-log.js'; const _dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -84,6 +85,7 @@ export class ServerService implements OnApplicationShutdown { logger: false, }); this.#fastify = fastify; + registerHttpAccessLog(fastify); // HSTS // 6months (15552000sec) @@ -281,8 +283,6 @@ export class ServerService implements OnApplicationShutdown { fastify.listen({ port: this.config.port, host: '0.0.0.0' }); } - - if (envOption.managed && this.config.rootUserName && this.config.adminUserName && this.config.rootPassword && this.config.adminPassword) { const hasUsers = await this.usersRepository.count() > 0; if (!hasUsers) { @@ -291,13 +291,13 @@ export class ServerService implements OnApplicationShutdown { const rootUser = await this.signupService.signup({ username: this.config.rootUserName, password: this.config.rootPassword, - ignorePreservedUsernames: true + ignorePreservedUsernames: true, }); const adminUser = await this.signupService.signup({ username: this.config.adminUserName, password: this.config.adminPassword, - ignorePreservedUsernames: true + ignorePreservedUsernames: true, }); const rootRole = await this.roleService.create({ @@ -338,8 +338,6 @@ export class ServerService implements OnApplicationShutdown { } await fastify.ready(); - - } @bindThis diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 219a1a0c87f..9131d6c2798 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; @@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown { private logger: Logger; private userIpHistories: Map>; private userIpHistoriesClearIntervalId: NodeJS.Timeout; - private Sentry: typeof import('@sentry/node') | null = null; constructor( @Inject(DI.meta) @@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown { private rateLimiterService: RateLimiterService, private roleService: RoleService, private apiLoggerService: ApiLoggerService, + private telemetryService: TelemetryService, ) { this.logger = this.apiLoggerService.logger; this.userIpHistories = new Map>(); @@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown { this.userIpHistoriesClearIntervalId = setInterval(() => { this.userIpHistories.clear(); }, 1000 * 60 * 60); - - if (this.config.sentryForBackend) { - import('@sentry/node').then((Sentry) => { - this.Sentry = Sentry; - }); - } } #sendApiError(reply: FastifyReply, err: ApiError): void { @@ -115,35 +110,31 @@ export class ApiCallService implements OnApplicationShutdown { throw err; } else { const errId = randomUUID(); - this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, + this.logger.write({ + level: 'error', + eventName: 'api.endpoint.failed', + message: `Internal error occurred in ${ep.name}: ${err.message}`, + attributes: { + 'api.endpoint': ep.name, + 'error.id': errId, + 'api.params': data, }, + error: err, }); - if (this.Sentry != null) { - this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { - level: 'error', - user: { - id: userId, + this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + level: 'error', + userId, + extra: { + ep: ep.name, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, }, - extra: { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, - }, - }, - }); - } + }, + }); throw new ApiError(null, { e: { @@ -160,7 +151,7 @@ export class ApiCallService implements OnApplicationShutdown { endpoint: IEndpoint & { exec: any }, request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, reply: FastifyReply, - ): void { + ): Promise { const body = request.method === 'GET' ? request.query : request.body; @@ -171,10 +162,11 @@ export class ApiCallService implements OnApplicationShutdown { : body?.['i']; if (token != null && typeof token !== 'string') { reply.code(400); - return; + return Promise.resolve(); } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, body, null, request).then((res) => { + + return this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => { + const call = this.call(endpoint, user, app, body, null, request).then((res) => { if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); } @@ -186,9 +178,11 @@ export class ApiCallService implements OnApplicationShutdown { if (user) { this.logIp(request, user); } + + return call; }).catch(err => { this.#sendAuthenticationError(reply, err); - }); + })); } @bindThis @@ -205,48 +199,54 @@ export class ApiCallService implements OnApplicationShutdown { reply.send(); return; } - const [path, cleanup] = await createTemp(); - await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartData.file.truncated) { - cleanup(); - reply.code(413); - reply.send(); - return; - } + try { + await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - const fields = {} as Record; - for (const [k, v] of Object.entries(multipartData.fields)) { - fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; - } + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartData.file.truncated) { + reply.code(413); + reply.send(); + return; + } - // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) - : fields['i']; - if (token != null && typeof token !== 'string') { - reply.code(400); - return; - } - this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, fields, { - name: multipartData.filename, - path: path, - }, request).then((res) => { - this.send(reply, res); - }).catch((err: ApiError) => { - this.#sendApiError(reply, err); - }); + const fields = {} as Record; + for (const [k, v] of Object.entries(multipartData.fields)) { + fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; + } - if (user) { - this.logIp(request, user); + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : fields['i']; + if (token != null && typeof token !== 'string') { + reply.code(400); + return; } - }).catch(err => { - this.#sendAuthenticationError(reply, err); - }); + + return await this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => { + const call = this.call(endpoint, user, app, fields, { + name: multipartData.filename, + path: path, + }, request).then((res) => { + this.send(reply, res); + }).catch((err: ApiError) => { + this.#sendApiError(reply, err); + }); + + if (user) { + this.logIp(request, user); + } + + return call; + }).catch(err => { + this.#sendAuthenticationError(reply, err); + })); + } finally { + cleanup(); + } } @bindThis @@ -376,8 +376,9 @@ export class ApiCallService implements OnApplicationShutdown { } } - if (ep.meta.requireModerator || ep.meta.requireAdmin) { - if (ep.meta.requireModerator && !await this.roleService.isModerator(user!)) { + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { + const myRoles = await this.roleService.getUserRoles(user!.id); + if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a moderator role.', code: 'ROLE_PERMISSION_DENIED', @@ -385,7 +386,7 @@ export class ApiCallService implements OnApplicationShutdown { id: 'd33d5333-db36-423d-a8f9-1a2b9549da41', }); } - if (ep.meta.requireAdmin && !await this.roleService.isAdministrator(user!)) { + if (ep.meta.requireAdmin && !myRoles.some(r => r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to an administrator role.', code: 'ROLE_PERMISSION_DENIED', @@ -395,9 +396,10 @@ export class ApiCallService implements OnApplicationShutdown { } } - if (ep.meta.requiredRolePolicy != null && !await this.roleService.isAdministrator(user!)) { + if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) { + const myRoles = await this.roleService.getUserRoles(user!.id); const policies = await this.roleService.getUserPolicies(user!.id); - if (!policies[ep.meta.requiredRolePolicy]) { + if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a required role.', code: 'ROLE_PERMISSION_DENIED', @@ -438,16 +440,10 @@ export class ApiCallService implements OnApplicationShutdown { } } - // API invoking - if (this.Sentry != null) { - return await this.Sentry.startSpan({ - name: 'API: ' + ep.name, - }, () => ep.exec(data, user, token, file, request.ip, request.headers, request) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); - } else { - return await ep.exec(data, user, token, file, request.ip, request.headers, request) - .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); - } + // The API span starts in handleRequest/handleMultipartRequest so it also covers + // authentication, rate limiting, and parameter validation. + return await ep.exec(data, user, token, file, request.ip, request.headers, request) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } @bindThis diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 66062021185..7aee23adda0 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -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 { // 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 { // 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); } diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts index a68e95bf3fa..155f2c40005 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -28,7 +28,7 @@ export const paramDef = { type: 'object', properties: { queue: { type: 'string', enum: QUEUE_TYPES }, - state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed', 'paused'] } }, + state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } }, search: { type: 'string' }, }, required: ['queue', 'state'], diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index b69699c3380..519abc74146 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -12,7 +12,7 @@ export const meta = { requireCredential: true, requireModerator: true, - kind: 'read:admin:emoji', + kind: 'read:admin:queue', res: { type: 'object', diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index f19ffa173bc..61aed000774 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -10,6 +10,7 @@ import { ApiError } from '@/server/api/error.js'; import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { RoleService } from '@/core/RoleService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { @@ -25,10 +26,10 @@ export const meta = { code: 'NO_SUCH_USER', id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d', }, - cannotResetPasswordOfRootUser: { - message: 'Cannot reset password of the root user.', - code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER', - id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999', + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d', }, }, @@ -66,6 +67,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + private roleService: RoleService, private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { @@ -75,8 +77,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchUser); } - if (this.serverSettings.rootUserId === user.id) { - throw new ApiError(meta.errors.cannotResetPasswordOfRootUser); + if (await this.roleService.isAdministrator(user) && me.id !== user.id) { + throw new ApiError(meta.errors.accessDenied); } const passwd = secureRndstr(8); diff --git a/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts b/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts index c074dd88648..9f6649c92fc 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-mfa.ts @@ -11,6 +11,7 @@ import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { @@ -27,6 +28,11 @@ export const meta = { code: 'NO_SUCH_USER', id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d', }, + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d', + }, }, } as const; @@ -47,6 +53,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private roleService: RoleService, private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { @@ -56,6 +63,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchUser); } + if (await this.roleService.isAdministrator(user) && me.id !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + await this.db.transaction(async (transactionalEntityManager) => { // パスキーを全て削除 await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id }); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 1812820ba29..b46552281f4 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -25,6 +26,11 @@ export const meta = { code: 'NO_SUCH_CHANNEL', id: 'c0031718-d573-4e85-928e-10039f1fbb68', }, + alreadyFollowing: { + message: 'You are already following that channel.', + code: 'ALREADY_FOLLOWING', + id: '7db31665-651e-40c1-8e6e-28e9ad829a2d', + }, }, } as const; @@ -52,7 +58,14 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingService.follow(me, channel); + try { + await this.channelFollowingService.follow(me, channel); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing); + } + throw e; + } }); } } diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts index ba48b0119e9..7b777e46c49 100644 --- a/packages/backend/src/server/api/endpoints/fetch-rss.ts +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -7,8 +7,11 @@ import Parser from 'rss-parser'; import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { ApiError } from '../error.js'; -const rssParser = new Parser(); +const MAX_URL_LENGTH = 8192; +const MAX_RESPONSE_SIZE = 1024 * 1024; +const MAX_CONCURRENT_REQUESTS = 32; export const meta = { tags: ['meta'], @@ -17,6 +20,34 @@ export const meta = { allowGet: true, cacheSec: 60 * 3, + limit: { + duration: 60 * 1000, + max: 300, + }, + + errors: { + invalidUrl: { + message: 'Invalid URL.', + code: 'INVALID_URL', + id: '89b7ee05-ccfc-4bdd-9b13-61172fd1e06c', + httpStatusCode: 400, + }, + fetchRssFailed: { + message: 'Failed to fetch RSS.', + code: 'FETCH_RSS_FAILED', + id: '8db5d3d8-31d7-452f-b0cc-ca3b8925de12', + kind: 'server', + httpStatusCode: 422, + }, + fetchRssUnavailable: { + message: 'RSS fetching is temporarily unavailable.', + code: 'FETCH_RSS_UNAVAILABLE', + id: '91e6ff44-c63f-4725-9ad0-b7a40d7f7655', + kind: 'server', + httpStatusCode: 503, + }, + }, + res: { type: 'object', properties: { @@ -215,21 +246,84 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export + private readonly inFlightRequests = new Map>>>(); + private activeRequestCount = 0; + constructor( private httpRequestService: HttpRequestService, ) { - super(meta, paramDef, async (ps, me) => { - const res = await this.httpRequestService.send(ps.url, { - method: 'GET', - headers: { - Accept: 'application/rss+xml, */*', - }, - timeout: 5000, - }); + super(meta, paramDef, async (ps) => { + const url = this.normalizeUrl(ps.url); + const inFlightRequest = this.inFlightRequests.get(url); + if (inFlightRequest != null) { + return await inFlightRequest; + } + + if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) { + throw new ApiError(meta.errors.fetchRssUnavailable); + } + + this.activeRequestCount++; + const request = this.fetchRss(url) + .catch(() => { + throw new ApiError(meta.errors.fetchRssFailed); + }) + .finally(() => { + this.inFlightRequests.delete(url); + this.activeRequestCount--; + }); + this.inFlightRequests.set(url, request); + + return await request; + }); + } + + private normalizeUrl(input: string): string { + if (input.length === 0 || input.length > MAX_URL_LENGTH) { + throw new ApiError(meta.errors.invalidUrl); + } + + let url: URL; + try { + url = new URL(input); + } catch { + throw new ApiError(meta.errors.invalidUrl); + } + + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username !== '' || + url.password !== '' + ) { + throw new ApiError(meta.errors.invalidUrl); + } + + url.hash = ''; + return url.href; + } + + private async fetchRss(url: string): Promise>> { + const res = await this.httpRequestService.send(url, { + method: 'GET', + headers: { + Accept: 'application/rss+xml, */*', + }, + timeout: 5000, + size: MAX_RESPONSE_SIZE, + }); - const text = await res.text(); + const finalUrl = new URL(res.url); + if (finalUrl.protocol !== 'http:' && finalUrl.protocol !== 'https:') { + throw new Error('Invalid final URL protocol'); + } - return rssParser.parseString(text); + const text = await res.text(); + const rssParser = new Parser({ + xml2js: { + async: true, + }, }); + + return await rssParser.parseString(text); } } diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 7c0dddb8274..143e823367a 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -30,6 +30,12 @@ export const meta = { code: 'NO_SUCH_NOTE', id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2', }, + + alreadyMuting: { + message: 'You are already muting that thread.', + code: 'ALREADY_MUTING', + id: 'c146e22d-1141-4b31-b28d-176371014d18', + }, }, } as const; @@ -59,14 +65,18 @@ export default class extends Endpoint { // eslint- throw err; }); - const _mutedNotes = await this.notesRepository.find({ - where: [{ - id: note.threadId ?? note.id, - }, { + // Check if already muting + const exist = await this.noteThreadMutingsRepository.exists({ + where: { threadId: note.threadId ?? note.id, - }], + userId: me.id, + }, }); + if (exist) { + throw new ApiError(meta.errors.alreadyMuting); + } + await this.noteThreadMutingsRepository.insert({ id: this.idService.gen(), threadId: note.threadId ?? note.id, diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index cd7d46007c2..6cbf48f02d7 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -10,9 +10,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; import { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -83,7 +83,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotTranslateInvisibleNote); } - if (note.text == null) { + let text = note.text ?? ''; + if (note.cw != null) { + text = `${note.cw}\n-----\n${text}`; + } + + if (text.trim() === '') { return; } @@ -95,7 +100,7 @@ export default class extends Endpoint { // eslint- if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; const params = new URLSearchParams(); - params.append('text', note.text); + params.append('text', text); params.append('target_lang', targetLang); const endpoint = this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; diff --git a/packages/backend/src/server/http-access-log.ts b/packages/backend/src/server/http-access-log.ts new file mode 100644 index 00000000000..21942062765 --- /dev/null +++ b/packages/backend/src/server/http-access-log.ts @@ -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 { + const result: Record = Object.create(null) as Record; + 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(); + + // リクエスト開始時の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(); + }); +} diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index e5ba8c596cd..7b7ee2c4257 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -3,13 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import type { SummalyResult } from '@misskey-dev/summaly'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; +import { deepClone } from '@/misc/clone.js'; import { query } from '@/misc/prelude/url.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import { LoggerService } from '@/core/LoggerService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; @@ -18,8 +20,9 @@ import { MiMeta } from '@/models/Meta.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() -export class UrlPreviewService { +export class UrlPreviewService implements OnApplicationShutdown { private logger: Logger; + private summaryCache: MemoryKVCache; private readonly summalyDefaultUserAgent: string; constructor( @@ -34,6 +37,7 @@ export class UrlPreviewService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('url-preview'); + this.summaryCache = new MemoryKVCache(1000 * 60 * 60, 100); // 1h, 100件 this.summalyDefaultUserAgent = `SummalyBot/${_SUMMALY_VERSION_} (${this.config.url}; +https://github.com/misskey-dev/summaly/blob/master/README.md)`; } @@ -80,19 +84,31 @@ export class UrlPreviewService { : `Getting preview of ${url}@${lang} ...`); try { - const summary = this.meta.urlPreviewSummaryProxyUrl - ? await this.fetchSummaryFromProxy(url, this.meta, lang) - : await this.fetchSummary(url, this.meta, lang); + const fetcher = async () => { + const result = await ( + this.meta.urlPreviewSummaryProxyUrl + ? this.fetchSummaryFromProxy(url, lang) + : this.fetchSummary(url, lang) + ); + + if (!result.url.startsWith('http://') && !result.url.startsWith('https://')) { + return undefined; + } + + if (result.player.url && !result.player.url.startsWith('http://') && !result.player.url.startsWith('https://')) { + return undefined; + } + + return result; + }; - this.logger.succ(`Got preview of ${url}: ${summary.title}`); + const summary = deepClone(await this.summaryCache.fetchMaybe(`${url}@${lang ?? '_DEFAULT_'}`, fetcher)); - if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); + if (summary == null) { + throw new Error('Invalid summary'); } - if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } + this.logger.succ(`Got preview of ${url}: ${summary.title}`); summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); @@ -120,7 +136,8 @@ export class UrlPreviewService { } } - private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise { + @bindThis + private async fetchSummary(url: string, lang?: string): Promise { const { summaly } = await import('@misskey-dev/summaly'); return summaly(url, { @@ -130,25 +147,36 @@ export class UrlPreviewService { http: this.httpRequestService.httpAgent, https: this.httpRequestService.httpsAgent, }, - userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent, - operationTimeout: meta.urlPreviewTimeout, - contentLengthLimit: meta.urlPreviewMaximumContentLength, - contentLengthRequired: meta.urlPreviewRequireContentLength, + userAgent: this.meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent, + operationTimeout: this.meta.urlPreviewTimeout, + contentLengthLimit: this.meta.urlPreviewMaximumContentLength, + contentLengthRequired: this.meta.urlPreviewRequireContentLength, }); } - private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise { - const proxy = meta.urlPreviewSummaryProxyUrl!; + @bindThis + private fetchSummaryFromProxy(url: string, lang?: string): Promise { + const proxy = this.meta.urlPreviewSummaryProxyUrl!; const queryStr = query({ url: url, lang: lang ?? 'ja-JP', followRedirects: this.meta.urlPreviewAllowRedirect, - userAgent: meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent, - operationTimeout: meta.urlPreviewTimeout, - contentLengthLimit: meta.urlPreviewMaximumContentLength, - contentLengthRequired: meta.urlPreviewRequireContentLength, + userAgent: this.meta.urlPreviewUserAgent ?? this.summalyDefaultUserAgent, + operationTimeout: this.meta.urlPreviewTimeout, + contentLengthLimit: this.meta.urlPreviewMaximumContentLength, + contentLengthRequired: this.meta.urlPreviewRequireContentLength, }); return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); } + + @bindThis + public dispose(): void { + this.summaryCache.dispose(); + } + + @bindThis + public onApplicationShutdown(): void { + this.dispose(); + } } diff --git a/packages/backend/test-federation/.gitignore b/packages/backend/test-federation/.gitignore index e00f952cb58..fd31053063b 100644 --- a/packages/backend/test-federation/.gitignore +++ b/packages/backend/test-federation/.gitignore @@ -4,3 +4,4 @@ volumes docker.env *.test.conf *.test.default.yml +*.test.config.json diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index 53e297f8678..8bc386c00c0 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -139,7 +139,7 @@ services: bash -c " npm install -g pnpm pnpm -F backend i --frozen-lockfile - pnpm exec tsgo -p ./packages/backend/test-federation + pnpm exec tsc -p ./packages/backend/test-federation node ./packages/backend/test-federation/built/daemon.js " diff --git a/packages/backend/test-federation/setup.sh b/packages/backend/test-federation/setup.sh index 15aa2eee7f3..f0e280d4dce 100644 --- a/packages/backend/test-federation/setup.sh +++ b/packages/backend/test-federation/setup.sh @@ -28,7 +28,7 @@ function generate { -days 500 if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi - if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi + if [ ! -f .config/$1.config.json ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi } generate a.test diff --git a/packages/backend/test-federation/test/abuse-report.test.ts b/packages/backend/test-federation/test/abuse-report.test.ts index 44342e77431..eae72df658a 100644 --- a/packages/backend/test-federation/test/abuse-report.test.ts +++ b/packages/backend/test-federation/test/abuse-report.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeAll } from 'vitest'; +import { describe, test, beforeAll, vi } from 'vitest'; import { rejects, strictEqual } from 'node:assert'; import * as Misskey from 'misskey-js'; -import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js'; +import { createAccount, createModerator, resolveRemoteUser, type LoginUser, WAIT_FOR_FEDERATION } from './utils.js'; describe('Abuse report', () => { describe('Forwarding report', () => { @@ -31,10 +31,14 @@ describe('Abuse report', () => { const reports = await aModerator.client.request('admin/abuse-user-reports', {}); const report = reports.filter(report => report.comment === comment)[0]; await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }); - await sleep(); - const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {}); - const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0]; + const reportInB = await vi.waitFor(async () => { + const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {}); + const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + strictEqual(reportInB != null, true); + return reportInB; + }, WAIT_FOR_FEDERATION); // NOTE: reporter is not Alice, and is not moderator in A strictEqual(reportInB.reporter.url, 'https://a.test/@system.actor'); strictEqual(reportInB.targetUserId, bob.id); diff --git a/packages/backend/test-federation/test/block.test.ts b/packages/backend/test-federation/test/block.test.ts index 0cc5ca046be..0c2e54e591d 100644 --- a/packages/backend/test-federation/test/block.test.ts +++ b/packages/backend/test-federation/test/block.test.ts @@ -1,9 +1,16 @@ -import { describe, test, beforeAll } from 'vitest'; +import { describe, test, beforeAll, vi } from 'vitest'; import { deepStrictEqual, rejects, strictEqual } from 'node:assert'; import * as Misskey from 'misskey-js'; -import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; +import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, WAIT_FOR_FEDERATION } from './utils.js'; describe('Block', () => { + async function waitForBlocked(blocked: LoginUser, blockerId: string): Promise { + await vi.waitFor(async () => { + const blocker = await blocked.client.request('users/show', { userId: blockerId }); + strictEqual(blocker.isBlocked, true); + }, WAIT_FOR_FEDERATION); + } + describe('Check follow', () => { let alice: LoginUser, bob: LoginUser; let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; @@ -22,7 +29,7 @@ describe('Block', () => { test('Cannot follow if blocked', async () => { await alice.client.request('blocking/create', { userId: bobInA.id }); - await sleep(); + await waitForBlocked(bob, aliceInB.id); await rejects( async () => await bob.client.request('following/create', { userId: aliceInB.id }), (err: any) => { @@ -104,7 +111,7 @@ describe('Block', () => { test('Cannot reply if blocked', async () => { await alice.client.request('blocking/create', { userId: bobInA.id }); - await sleep(); + await waitForBlocked(bob, aliceInB.id); const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); @@ -147,7 +154,7 @@ describe('Block', () => { test('Cannot reaction if blocked', async () => { await alice.client.request('blocking/create', { userId: bobInA.id }); - await sleep(); + await waitForBlocked(bob, aliceInB.id); const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); @@ -211,7 +218,7 @@ describe('Block', () => { /** NOTE: You should mute the target to stop receiving notifications */ test('Can mention and notified even if blocked', async () => { await alice.client.request('blocking/create', { userId: bobInA.id }); - await sleep(); + await waitForBlocked(bob, aliceInB.id); const text = `@${alice.username}@a.test plz unblock me!`; await assertNotificationReceived( diff --git a/packages/backend/test-federation/test/drive.test.ts b/packages/backend/test-federation/test/drive.test.ts index 8c392148fc6..727f0cdff45 100644 --- a/packages/backend/test-federation/test/drive.test.ts +++ b/packages/backend/test-federation/test/drive.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeAll } from 'vitest'; +import { describe, test, beforeAll, vi } from 'vitest'; import assert, { strictEqual } from 'node:assert'; import * as Misskey from 'misskey-js'; -import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile, waitForFollowRelation, WAIT_FOR_FEDERATION } from './utils.js'; const bAdmin = await fetchAdmin('b.test'); @@ -106,18 +106,19 @@ describe('Drive', () => { ]); await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRelation(bob, alice, 1); }); test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { const file = await uploadFile('a.test', alice); await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] }); - await sleep(); - const notes = await bob.client.request('notes/timeline', {}); - strictEqual(notes.length, 1); - const noteInB = notes[0]; + const noteInB = await vi.waitFor(async () => { + const notes = await bob.client.request('notes/timeline', {}); + strictEqual(notes.length, 1); + return notes[0]; + }, WAIT_FOR_FEDERATION); assert(noteInB.files != null); strictEqual(noteInB.files.length, 1); strictEqual(noteInB.files[0].isSensitive, true); diff --git a/packages/backend/test-federation/test/emoji.test.ts b/packages/backend/test-federation/test/emoji.test.ts index 308323a7cec..056e31db1b8 100644 --- a/packages/backend/test-federation/test/emoji.test.ts +++ b/packages/backend/test-federation/test/emoji.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeAll } from 'vitest'; +import { describe, test, beforeAll, vi } from 'vitest'; import assert, { deepStrictEqual, strictEqual } from 'assert'; import * as Misskey from 'misskey-js'; -import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js'; +import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, waitForFollowRelation, WAIT_FOR_FEDERATION } from './utils.js'; describe('Emoji', () => { let alice: LoginUser, bob: LoginUser; @@ -19,18 +19,24 @@ describe('Emoji', () => { ]); await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRelation(bob, alice, 1); }); + async function waitForNoteInB(cond: (note: Misskey.entities.Note) => boolean): Promise { + return await vi.waitFor(async () => { + const notes = await bob.client.request('notes/timeline', {}); + const noteInB = notes.at(0); + strictEqual(noteInB != null && cond(noteInB), true); + return noteInB!; + }, WAIT_FOR_FEDERATION); + } + test('Custom emoji are delivered with Note delivery', async () => { const emoji = await addCustomEmoji('a.test'); await alice.client.request('notes/create', { text: `I love :${emoji.name}:` }); - await sleep(); - const notes = await bob.client.request('notes/timeline', {}); - const noteInB = notes[0]; + const noteInB = await waitForNoteInB(note => note.text === `I love \u200b:${emoji.name}:\u200b`); - strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`); assert(noteInB.emojis != null); assert(emoji.name in noteInB.emojis); strictEqual(noteInB.emojis[emoji.name], emoji.url); @@ -39,23 +45,23 @@ describe('Emoji', () => { test('Custom emoji are delivered with Reaction delivery', async () => { const emoji = await addCustomEmoji('a.test'); const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; - await sleep(); + await waitForNoteInB(noteInB => noteInB.uri === `https://a.test/notes/${note.id}`); await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` }); - await sleep(); - const noteInB = (await bob.client.request('notes/timeline', {}))[0]; - deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1); + const noteInB = await waitForNoteInB(noteInB => noteInB.reactions[`:${emoji.name}@a.test:`] === 1); deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url); }); test('Custom emoji are delivered with Profile delivery', async () => { const emoji = await addCustomEmoji('a.test'); const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` }); - await sleep(); - const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(renewedaliceInB.name, renewedAlice.name); + const renewedaliceInB = await vi.waitFor(async () => { + const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedaliceInB.name, renewedAlice.name); + return renewedaliceInB; + }, WAIT_FOR_FEDERATION); assert(emoji.name in renewedaliceInB.emojis); strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url); }); @@ -63,12 +69,9 @@ describe('Emoji', () => { test('Local-only custom emoji aren\'t delivered with Note delivery', async () => { const emoji = await addCustomEmoji('a.test', { localOnly: true }); await alice.client.request('notes/create', { text: `I love :${emoji.name}:` }); - await sleep(); - const notes = await bob.client.request('notes/timeline', {}); - const noteInB = notes[0]; + const noteInB = await waitForNoteInB(note => note.text === `I love \u200b:${emoji.name}:\u200b`); - strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`); // deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?) deepStrictEqual({ ...noteInB.emojis }, {}); }); @@ -76,12 +79,11 @@ describe('Emoji', () => { test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => { const emoji = await addCustomEmoji('a.test', { localOnly: true }); const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; - await sleep(); + await waitForNoteInB(noteInB => noteInB.uri === `https://a.test/notes/${note.id}`); await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` }); - await sleep(); - const noteInB = (await bob.client.request('notes/timeline', {}))[0]; + const noteInB = await waitForNoteInB(noteInB => noteInB.reactions['❤'] === 1); deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 }); deepStrictEqual({ ...noteInB.reactionEmojis }, {}); }); @@ -89,10 +91,12 @@ describe('Emoji', () => { test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => { const emoji = await addCustomEmoji('a.test', { localOnly: true }); const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` }); - await sleep(); - const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(renewedaliceInB.name, renewedAlice.name); + const renewedaliceInB = await vi.waitFor(async () => { + const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedaliceInB.name, renewedAlice.name); + return renewedaliceInB; + }, WAIT_FOR_FEDERATION); deepStrictEqual({ ...renewedaliceInB.emojis }, {}); }); }); diff --git a/packages/backend/test-federation/test/move.test.ts b/packages/backend/test-federation/test/move.test.ts index 0801ce84ee2..d219d5b5b33 100644 --- a/packages/backend/test-federation/test/move.test.ts +++ b/packages/backend/test-federation/test/move.test.ts @@ -1,6 +1,6 @@ import { describe, test, beforeAll } from 'vitest'; import assert, { strictEqual } from 'node:assert'; -import { createAccount, type LoginUser, sleep } from './utils.js'; +import { createAccount, type LoginUser, waitForFollowers, waitForFollowing } from './utils.js'; describe('Move', () => { test('Minimum move', async () => { @@ -30,7 +30,9 @@ describe('Move', () => { // Move @alice@a.test ==> @bob@b.test await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] }); await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` }); - await sleep(); + + await waitForFollowing(carol, 2); + await waitForFollowers(bob, 1); }); test('Check from follower', async () => { diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts index d9556b0e932..57fc0136743 100644 --- a/packages/backend/test-federation/test/note.test.ts +++ b/packages/backend/test-federation/test/note.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeAll, afterAll } from 'vitest'; +import { describe, test, beforeAll, afterAll, vi } from 'vitest'; import assert, { rejects, strictEqual } from 'node:assert'; import * as Misskey from 'misskey-js'; -import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js'; +import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile, waitForFollowRelation, WAIT_FOR_FEDERATION } from './utils.js'; describe('Note', () => { let alice: LoginUser, bob: LoginUser; @@ -85,10 +85,10 @@ describe('Note', () => { ]); strictEqual(aliceInB.id, resolvedNote.userId); - await sleep(); - - const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId }); - strictEqual(resolvedReplyedNote.repliesCount, 1); + await vi.waitFor(async () => { + const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId! }); + strictEqual(resolvedReplyedNote.repliesCount, 1); + }, WAIT_FOR_FEDERATION); }); test('Consistency of Renote', async () => { @@ -146,27 +146,26 @@ describe('Note', () => { carol = await createAccount('a.test'); await carol.client.request('following/create', { userId: bobInA.id }); - await sleep(); + await waitForFollowRelation(carol, bob, 1); }); test('Check', async () => { const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; const noteInA = await resolveRemoteNote('b.test', note.id, carol); await bob.client.request('notes/delete', { noteId: note.id }); - await sleep(); - await rejects( + await vi.waitFor(async () => await rejects( async () => await carol.client.request('notes/show', { noteId: noteInA.id }), (err: any) => { strictEqual(err.code, 'NO_SUCH_NOTE'); return true; }, - ); + ), WAIT_FOR_FEDERATION); }); afterAll(async () => { await carol.client.request('following/delete', { userId: bobInA.id }); - await sleep(); + await waitForFollowRelation(carol, bob, 0); }); }); @@ -175,18 +174,20 @@ describe('Note', () => { const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; const noteInA = await resolveRemoteNote('b.test', note.id, alice); await alice.client.request('notes/create', { renoteId: noteInA.id }); - await sleep(); + await vi.waitFor(async () => { + const renotes = await bob.client.request('notes/renotes', { noteId: note.id }); + strictEqual(renotes.length, 1); + }, WAIT_FOR_FEDERATION); await bob.client.request('notes/delete', { noteId: note.id }); - await sleep(); - await rejects( + await vi.waitFor(async () => await rejects( async () => await alice.client.request('notes/show', { noteId: noteInA.id }), (err: any) => { strictEqual(err.code, 'NO_SUCH_NOTE'); return true; }, - ); + ), WAIT_FOR_FEDERATION); }); }); @@ -195,18 +196,20 @@ describe('Note', () => { const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; const noteInA = await resolveRemoteNote('b.test', note.id, alice); await alice.client.request('notes/create', { text: 'Hello Bob!', replyId: noteInA.id }); - await sleep(); + await vi.waitFor(async () => { + const replies = await bob.client.request('notes/replies', { noteId: note.id }); + strictEqual(replies.length, 1); + }, WAIT_FOR_FEDERATION); await bob.client.request('notes/delete', { noteId: note.id }); - await sleep(); - await rejects( + await vi.waitFor(async () => await rejects( async () => await alice.client.request('notes/show', { noteId: noteInA.id }), (err: any) => { strictEqual(err.code, 'NO_SUCH_NOTE'); return true; }, - ); + ), WAIT_FOR_FEDERATION); }); }); @@ -269,16 +272,22 @@ describe('Note', () => { }); describe('Reaction', () => { + async function waitForReactions(noteId: string, count: number): Promise { + return await vi.waitFor(async () => { + const reactions = await alice.client.request('notes/reactions', { noteId }); + strictEqual(reactions.length, count); + return reactions; + }, WAIT_FOR_FEDERATION); + } + describe('Consistency', () => { test('Unicode reaction', async () => { const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); const reaction = '😅'; await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction }); - await sleep(); - const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); - strictEqual(reactions.length, 1); + const reactions = await waitForReactions(note.id, 1); strictEqual(reactions[0].type, reaction); strictEqual(reactions[0].user.id, bobInA.id); }); @@ -288,10 +297,8 @@ describe('Note', () => { const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); const emoji = await addCustomEmoji('b.test'); await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` }); - await sleep(); - const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); - strictEqual(reactions.length, 1); + const reactions = await waitForReactions(note.id, 1); strictEqual(reactions[0].type, `:${emoji.name}@b.test:`); strictEqual(reactions[0].user.id, bobInA.id); }); @@ -303,10 +310,8 @@ describe('Note', () => { const noteInB = await resolveRemoteNote('a.test', note.id, bob); const emoji = await addCustomEmoji('b.test'); await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` }); - await sleep(); - const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); - strictEqual(reactions.length, 1); + const reactions = await waitForReactions(note.id, 1); strictEqual(reactions[0].type, '❤'); }); @@ -319,10 +324,8 @@ describe('Note', () => { const noteInB = await resolveRemoteNote('a.test', note.id, bob); const emoji = await addCustomEmoji('b.test', { isSensitive: true }); await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` }); - await sleep(); - const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); - strictEqual(reactions.length, 1); + const reactions = await waitForReactions(note.id, 1); strictEqual(reactions[0].type, `:${emoji.name}@b.test:`); }); }); @@ -340,12 +343,13 @@ describe('Note', () => { const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote; const noteInA = await resolveRemoteNote('b.test', note.id, carol); await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 }); - await sleep(); - const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id }); - assert(noteAfterVote.poll != null); - strictEqual(noteAfterVote.poll.choices[0].votes, 1); - strictEqual(noteAfterVote.poll.choices[1].votes, 0); + await vi.waitFor(async () => { + const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id }); + assert(noteAfterVote.poll != null); + strictEqual(noteAfterVote.poll.choices[0].votes, 1); + strictEqual(noteAfterVote.poll.choices[1].votes, 0); + }, WAIT_FOR_FEDERATION); }); }); @@ -362,7 +366,7 @@ describe('Note', () => { ]); await bobRemoteFollower.client.request('following/create', { userId: bobInA.id }); - await sleep(); + await waitForFollowRelation(bobRemoteFollower, bob, 1); }); test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => { @@ -370,12 +374,13 @@ describe('Note', () => { // NOTE: resolve before voting const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower); await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 }); - await sleep(); - const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id }); - assert(noteAfterVote.poll != null); - strictEqual(noteAfterVote.poll.choices[0].votes, 1); - strictEqual(noteAfterVote.poll.choices[1].votes, 0); + await vi.waitFor(async () => { + const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id }); + assert(noteAfterVote.poll != null); + strictEqual(noteAfterVote.poll.choices[0].votes, 1); + strictEqual(noteAfterVote.poll.choices[1].votes, 0); + }, WAIT_FOR_FEDERATION); }); }); }); diff --git a/packages/backend/test-federation/test/notification.test.ts b/packages/backend/test-federation/test/notification.test.ts index 7058da8a42b..df643632d10 100644 --- a/packages/backend/test-federation/test/notification.test.ts +++ b/packages/backend/test-federation/test/notification.test.ts @@ -1,6 +1,6 @@ import { describe, test, beforeAll, afterAll } from 'vitest'; import * as Misskey from 'misskey-js'; -import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; +import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, waitForFollowing, waitForFollowRelation } from './utils.js'; describe('Notification', () => { let alice: LoginUser, bob: LoginUser; @@ -27,8 +27,12 @@ describe('Notification', () => { true, ); + // NOTE: 次のテストで再度フォローする。a.test が Undo Follow を処理する前に次の Follow が + // 届くと「すでにフォロー関係が存在」扱いで Accept だけ返されて follow 通知が発生せず、 + // Undo が後勝ちした場合はその Accept すら返らなくなる。 + // b.test 側の followings は同期的に消えるので、a.test への反映まで待つ必要がある await bob.client.request('following/delete', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRelation(bob, alice, 0); }); test('Get notification when get followed', async () => { @@ -40,7 +44,12 @@ describe('Notification', () => { ); }); - afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id })); + afterAll(async () => { + // NOTE: follow 通知は a.test 側の処理で発生するため、この時点で b.test が Accept を + // 受け取っているとは限らない。そのまま解除すると NOT_FOLLOWING になる + await waitForFollowing(bob, 1); + await bob.client.request('following/delete', { userId: aliceInB.id }); + }); }); describe('Note', () => { diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts index 191bd416b9b..e602ef93615 100644 --- a/packages/backend/test-federation/test/timeline.test.ts +++ b/packages/backend/test-federation/test/timeline.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeAll, afterAll } from 'vitest'; +import { describe, test, beforeAll, afterAll, vi } from 'vitest'; import { strictEqual } from 'assert'; import * as Misskey from 'misskey-js'; -import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js'; +import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole, waitForFollowRelation, FEDERATION_TIMEOUT, WAIT_FOR_FEDERATION } from './utils.js'; const bAdmin = await fetchAdmin('b.test'); @@ -21,7 +21,9 @@ describe('Timeline', () => { ]); await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + // NOTE: 配送側 (a.test) だけでなく、受信側のホームタイムラインへの fan-out にも + // followings が要るので、両サーバーへ反映されるまで待つ + await waitForFollowRelation(bob, alice, 1); }); type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag'); @@ -52,6 +54,7 @@ describe('Timeline', () => { }, 'note', msg => msg.text === text, channelParams, + expect ? FEDERATION_TIMEOUT : undefined, ); strictEqual(streamingFired, expect); @@ -63,9 +66,19 @@ describe('Timeline', () => { endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } : {}; + const noteUri = `https://a.test/notes/${note!.id}`; + const fetchNoteInB = async () => (await (bob.client.request as Request)(endpoint, params)) + .filter(({ uri }) => uri === noteUri).pop(); + await sleep(); - const notes = await (bob.client.request as Request)(endpoint, params); - const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop(); + // 届かないはずのケースは待っても意味がないので 1 回だけ取得する + const noteInB = expect + ? await vi.waitFor(async () => { + const noteInB = await fetchNoteInB(); + strictEqual(noteInB != null, true); + return noteInB!; + }, WAIT_FOR_FEDERATION) + : await fetchNoteInB(); const endpointFired = noteInB != null; strictEqual(endpointFired, expect); @@ -75,13 +88,14 @@ describe('Timeline', () => { 'b.test', bob, noteInB!.id, async () => await alice.client.request('notes/delete', { noteId: note!.id }), msg => msg.type === 'deleted' && msg.id === noteInB!.id, + FEDERATION_TIMEOUT, ); strictEqual(streamingFired, true); - await sleep(); - const notes = await (bob.client.request as Request)(endpoint, params); - const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`); - strictEqual(endpointFired, true); + await vi.waitFor(async () => { + const endpointFired = await fetchNoteInB() == null; + strictEqual(endpointFired, true); + }, WAIT_FOR_FEDERATION); } } diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index c6a93c2dd5f..bf8354c23c5 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -1,7 +1,7 @@ -import { describe, test, beforeAll } from 'vitest'; +import { describe, test, beforeAll, vi } from 'vitest'; import assert, { rejects, strictEqual } from 'node:assert'; import * as Misskey from 'misskey-js'; -import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, waitForFollowers, waitForFollowing, waitForFollowRelation, WAIT_FOR_FEDERATION, WAIT_FOR_SLOW_FEDERATION } from './utils.js'; const [aAdmin, bAdmin] = await Promise.all([ fetchAdmin('a.test'), @@ -70,7 +70,10 @@ describe('User', () => { alice.client.request('following/create', { userId: bobInA.id }), bob.client.request('following/create', { userId: aliceInB.id }), ]); - await sleep(); + await Promise.all([ + waitForFollowRelation(alice, bob, 1), + waitForFollowRelation(bob, alice, 1), + ]); }); test('Visibility set public by default', async () => { @@ -139,13 +142,16 @@ describe('User', () => { test('Becoming a cat is sent to their followers', async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + // Update は「配送時点のフォロワー」にしか配送されないので、 + // フォローが a.test 側に反映されてから i/update する + await waitForFollowRelation(bob, alice, 1); await alice.client.request('i/update', { isCat: true }); - await sleep(); - const res = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(res.isCat, true); + await vi.waitFor(async () => { + const res = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(res.isCat, true); + }, WAIT_FOR_FEDERATION); }); }); @@ -161,6 +167,7 @@ describe('User', () => { aliceInB = await resolveRemoteUser('a.test', alice.id, bob); await bob.client.request('following/create', { userId: aliceInB.id }); + await waitForFollowRelation(bob, alice, 1); }); test('Pinning localOnly Note is not delivered', async () => { @@ -186,20 +193,23 @@ describe('User', () => { test('Pinning normal Note is delivered', async () => { pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote; await alice.client.request('i/pin', { noteId: pinnedNote.id }); - await sleep(); - const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(_aliceInB.pinnedNoteIds.length, 1); + const _aliceInB = await vi.waitFor(async () => { + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 1); + return _aliceInB; + }, WAIT_FOR_FEDERATION); const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob); strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id); }); test('Unpinning normal Note is delivered', async () => { await alice.client.request('i/unpin', { noteId: pinnedNote.id }); - await sleep(); - const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(_aliceInB.pinnedNoteIds.length, 0); + await vi.waitFor(async () => { + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }, WAIT_FOR_FEDERATION); }); }); }); @@ -224,7 +234,7 @@ describe('User', () => { beforeAll(async () => { await alice.client.request('following/create', { userId: bobInA.id }); - await sleep(); + await waitForFollowRelation(alice, bob, 1); }); test('Check consistency with `users/following` and `users/followers` endpoints', async () => { @@ -247,7 +257,7 @@ describe('User', () => { beforeAll(async () => { await alice.client.request('following/delete', { userId: bobInA.id }); - await sleep(); + await waitForFollowRelation(alice, bob, 0); }); test('Check consistency with `users/following` and `users/followers` endpoints', async () => { @@ -268,6 +278,13 @@ describe('User', () => { }); describe('Follow requests', () => { + async function waitForFollowRequests(followee: LoginUser, count: number): Promise { + await vi.waitFor(async () => { + const requests = await followee.client.request('following/requests/list', {}); + strictEqual(requests.length, count); + }, WAIT_FOR_FEDERATION); + } + let alice: LoginUser, bob: LoginUser; let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; @@ -289,7 +306,7 @@ describe('User', () => { describe('Bob sends follow request to Alice', () => { beforeAll(async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRequests(alice, 1); }); test('Alice should have a request', async () => { @@ -303,7 +320,7 @@ describe('User', () => { describe('Alice cancels it', () => { beforeAll(async () => { await bob.client.request('following/requests/cancel', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRequests(alice, 0); }); test('Alice should have no requests', async () => { @@ -316,10 +333,13 @@ describe('User', () => { describe('Send follow request from Bob to Alice and reject', () => { beforeAll(async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRequests(alice, 1); await alice.client.request('following/requests/reject', { userId: bobInA.id }); - await sleep(); + await vi.waitFor(async () => { + const sent = await bob.client.request('following/requests/sent', {}); + strictEqual(sent.length, 0); + }, WAIT_FOR_FEDERATION); }); test('Bob should have no requests', async () => { @@ -341,10 +361,10 @@ describe('User', () => { describe('Send follow request from Bob to Alice and accept', () => { beforeAll(async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); + await waitForFollowRequests(alice, 1); await alice.client.request('following/requests/accept', { userId: bobInA.id }); - await sleep(); + await waitForFollowing(bob, 1); }); test('Bob follows Alice', async () => { @@ -375,16 +395,10 @@ describe('User', () => { test('Bob follows Alice, and Alice deleted themself', async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - const followers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(followers.length, 1); // followed by Bob + await waitForFollowRelation(bob, alice, 1); // followed by Bob await alice.client.request('i/delete-account', { password: alice.password }); - await sleep(); - - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 0); // no following relation + await waitForFollowing(bob, 0, WAIT_FOR_SLOW_FEDERATION); // no following relation await rejects( async () => await bob.client.request('following/create', { userId: aliceInB.id }), @@ -414,10 +428,7 @@ describe('User', () => { test('Bob follows Alice, then Alice gets deleted in B server', async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - const followers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(followers.length, 1); // followed by Bob + await waitForFollowRelation(bob, alice, 1); // followed by Bob await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id }); await sleep(); @@ -473,16 +484,10 @@ describe('User', () => { test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => { await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - const followers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(followers.length, 1); // followed by Bob + await waitForFollowRelation(bob, alice, 1); // followed by Bob await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); - await sleep(); - - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 0); // no following relation + await waitForFollowing(bob, 0, WAIT_FOR_SLOW_FEDERATION); // no following relation await rejects( async () => await bob.client.request('following/create', { userId: aliceInB.id }), @@ -528,10 +533,9 @@ describe('User', () => { */ test('Alice can follow Bob', async () => { await alice.client.request('following/create', { userId: bobInA.id }); - await sleep(); + await waitForFollowers(bob, 1); // followed by Alice const bobFollowers = await bob.client.request('users/followers', { userId: bob.id }); - strictEqual(bobFollowers.length, 1); // followed by Alice assert(bobFollowers[0].follower != null); const renewedaliceInB = bobFollowers[0].follower; assert(aliceInB.username === renewedaliceInB.username); @@ -543,10 +547,7 @@ describe('User', () => { // Bob tries to follow Alice await bob.client.request('following/create', { userId: renewedaliceInB.id }); - await sleep(); - - const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(aliceFollowers.length, 1); + await waitForFollowers(alice, 1); // FIXME: but resolving still fails ... await rejects( diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts index 6f09f13f177..0577042f2fc 100644 --- a/packages/backend/test-federation/test/utils.ts +++ b/packages/backend/test-federation/test/utils.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, strictEqual } from 'assert'; +import { vi } from 'vitest'; import { readFile } from 'fs/promises'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; @@ -40,6 +41,21 @@ export async function sleep(ms = 250): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +type WaitForOptions = NonNullable[1]>; + +/** 連合の反映を待つ上限。`vi.waitFor()` を使わない待ち合わせ (ストリーミング) にも使う */ +export const FEDERATION_TIMEOUT = 10000; + +/** + * 連合の反映は非同期なので、固定時間の `sleep()` だけで待つと CI の負荷次第で容易に flaky になる。 + * 「一定時間待つ」のではなく「条件が満たされるまで待つ」ために `vi.waitFor()` へ渡すオプション。 + * (`vi.waitFor()` の既定値は timeout 1000ms / interval 50ms で、連合の反映待ちには短すぎる) + */ +export const WAIT_FOR_FEDERATION: WaitForOptions = { timeout: FEDERATION_TIMEOUT, interval: 250 }; + +/** アカウント削除・凍結など、明らかに時間のかかる処理を待つ場合の {@link WAIT_FOR_FEDERATION} */ +export const WAIT_FOR_SLOW_FEDERATION: WaitForOptions = { timeout: 30000, interval: 500 }; + async function signin( host: Host, params: Misskey.entities.SigninFlowRequest, @@ -155,6 +171,37 @@ export async function createRole( }); } +/** + * フォローは Follow → Accept の往復で成立するので、`sleep()` で待つと + * 「配送時点のフォロワー」に依存する配送 (Update / Note など) を取りこぼして flaky になる。 + * followee 側から見たフォロワー数が期待値になるまで待つ。 + */ +export async function waitForFollowers(followee: LoginUser, count: number, options: WaitForOptions = WAIT_FOR_FEDERATION): Promise { + await vi.waitFor(async () => { + const followers = await followee.client.request('users/followers', { userId: followee.id }); + strictEqual(followers.length, count); + }, options); +} + +/** {@link waitForFollowers} の follower 側版 (Accept が返ってきたことを確認する) */ +export async function waitForFollowing(follower: LoginUser, count: number, options: WaitForOptions = WAIT_FOR_FEDERATION): Promise { + await vi.waitFor(async () => { + const following = await follower.client.request('users/following', { userId: follower.id }); + strictEqual(following.length, count); + }, options); +} + +/** + * フォロー関係が両方のサーバーで反映されるまで待つ。 + * 特に理由が無ければこちらを使う。 + */ +export async function waitForFollowRelation(follower: LoginUser, followee: LoginUser, count: number, options: WaitForOptions = WAIT_FOR_FEDERATION): Promise { + await Promise.all([ + waitForFollowing(follower, count, options), + waitForFollowers(followee, count, options), + ]); +} + export async function resolveRemoteUser( host: Host, id: string, @@ -224,6 +271,9 @@ export function deepStrictEqualWithExcludedFields(actual: T, expected: T, exc deepStrictEqual(_actual, _expected); } +/** 「発火しない」ことの確認に使う待ち時間 */ +const NOT_FIRED_TIMEOUT = 500; + export async function isFired( host: Host, user: { i: string }, @@ -233,8 +283,11 @@ export async function isFired[0]) => boolean, params?: Misskey.Channels[C]['params'], + timeout = NOT_FIRED_TIMEOUT, ): Promise { const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + // 先にイベントを受け取った場合でもタイマーが残り続けないよう、必ず解除する + let timer: ReturnType | undefined; try { const connection = stream.useChannel(channel, params); @@ -249,9 +302,12 @@ export async function isFired false), + new Promise(resolve => { + timer = setTimeout(() => resolve(false), timeout); + }), ]); } finally { + clearTimeout(timer); stream.close(); } }; @@ -262,8 +318,11 @@ export async function isNoteUpdatedEventFired( noteId: string, trigger: () => Promise, cond: (msg: Parameters[0]) => boolean, + timeout = NOT_FIRED_TIMEOUT, ): Promise { const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + // 先にイベントを受け取った場合でもタイマーが残り続けないよう、必ず解除する + let timer: ReturnType | undefined; try { stream.send('s', { id: noteId }); @@ -279,9 +338,12 @@ export async function isNoteUpdatedEventFired( return await Promise.race([ receivePromise, - sleep(500).then(() => false), + new Promise(resolve => { + timer = setTimeout(() => resolve(false), timeout); + }), ]); } finally { + clearTimeout(timer); stream.close(); } }; @@ -293,11 +355,26 @@ export async function assertNotificationReceived( cond: (notification: Misskey.entities.Notification) => boolean, expect: boolean, ) { - const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond); + const streamingFired = await isFired( + receiverHost, + receiver, + 'main', + trigger, + 'notification', + cond, + undefined, + expect ? FEDERATION_TIMEOUT : undefined, + ); strictEqual(streamingFired, expect); - const endpointFired = await receiver.client.request('i/notifications', {}) + const fetchEndpointFired = async () => await receiver.client.request('i/notifications', {}) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition .then(([notification]) => notification != null ? cond(notification) : false); - strictEqual(endpointFired, expect); + + if (expect) { + await vi.waitFor(async () => strictEqual(await fetchEndpointFired(), true), WAIT_FOR_FEDERATION); + } else { + // 届かないことの確認は待っても意味がないので 1 回だけ確認する + strictEqual(await fetchEndpointFired(), false); + } } diff --git a/packages/backend/test/e2e/channel.ts b/packages/backend/test/e2e/channel.ts new file mode 100644 index 00000000000..c2fd98cbf41 --- /dev/null +++ b/packages/backend/test/e2e/channel.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { describe, beforeAll, test } from 'vitest'; +import { api, castAsError, signup } from '../utils.js'; +import type * as misskey from 'misskey-js'; + +describe('Channel', () => { + let alice: misskey.entities.SignupResponse; + beforeAll(async () => { + alice = await signup({ username: 'alice' }); + }); + + describe('Follow', () => { + let channel: misskey.entities.ChannelsCreateResponse; + + beforeAll(async () => { + const res = await api('channels/create', { name: 'follow-test-channel' }, alice); + channel = res.body; + }); + + test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => { + const res1 = await api('channels/follow', { channelId: channel.id }, alice); + assert.strictEqual(res1.status, 204); + + const res2 = await api('channels/follow', { channelId: channel.id }, alice); + assert.strictEqual(res2.status, 400); + assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING'); + }); + }); +}); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index fa4a13b1719..001501fa89f 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -14,7 +14,7 @@ import { mockDeep } from 'vitest-mock-extended'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfo, FileInfoService } from '@/core/FileInfoService.js'; //import { DI } from '@/di-symbols.js'; -import { AiService } from '@/core/AiService.js'; +import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { TestingModule } from '@nestjs/testing'; @@ -41,13 +41,13 @@ describe('FileInfoService', () => { GlobalModule, ], providers: [ - AiService, + SensitiveMediaDetectionService, LoggerService, FileInfoService, ], }) .useMocker((token) => { - //if (token === AiService) { + //if (token === SensitiveMediaDetectionService) { // return { }; //} if (typeof token === 'function') { diff --git a/packages/backend/test/unit/AiService.ts b/packages/backend/test/unit/SensitiveMediaDetectionService.ts similarity index 64% rename from packages/backend/test/unit/AiService.ts rename to packages/backend/test/unit/SensitiveMediaDetectionService.ts index 1933bd72712..58155e69326 100644 --- a/packages/backend/test/unit/AiService.ts +++ b/packages/backend/test/unit/SensitiveMediaDetectionService.ts @@ -4,19 +4,12 @@ */ import { describe, test, expect, vi, beforeEach } from 'vitest'; -import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js'; import type { MiMeta } from '@/models/_.js'; import type { HttpRequestService } from '@/core/HttpRequestService.js'; import type { LoggerService } from '@/core/LoggerService.js'; +import { SensitiveMediaDetectionService, type Prediction } from '@/core/SensitiveMediaDetectionService.js'; -// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と -// レスポンス解釈を検証する。 -const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() })); -vi.mock('node-fetch', () => ({ default: fetchMock })); - -const { AiService } = await import('@/core/AiService.js'); - -let getAgentByUrlMock: ReturnType; +const sendMock = vi.fn(); const DEFAULT_META = { sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null, @@ -25,14 +18,13 @@ const DEFAULT_META = { sensitiveMediaDetectionMaxImagesPerRequest: 4, }; -function makeService(metaOverrides: Partial = {}): AiServiceType { +function makeService(metaOverrides: Partial = {}): SensitiveMediaDetectionService { const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta; - getAgentByUrlMock = vi.fn(() => undefined); - const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService; + const httpRequestService = { send: sendMock } as unknown as HttpRequestService; const loggerService = { getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }), } as unknown as LoggerService; - return new AiService(meta, httpRequestService, loggerService); + return new SensitiveMediaDetectionService(meta, httpRequestService, loggerService); } function neutral(): Prediction[] { @@ -50,13 +42,13 @@ function okResponse(results: unknown[]) { const buf = (s: string) => Buffer.from(s); -describe('AiService', () => { +describe('SensitiveMediaDetectionService', () => { beforeEach(() => { - fetchMock.mockReset(); + sendMock.mockReset(); }); test('正常: 送信順を保った予測値配列を返す', async () => { - fetchMock.mockResolvedValue(okResponse([ + sendMock.mockResolvedValue(okResponse([ { success: true, predictions: neutral() }, { success: true, predictions: [{ className: 'Porn', probability: 0.8 }] }, ])); @@ -66,30 +58,35 @@ describe('AiService', () => { [{ className: 'Neutral', probability: 0.99 }], [{ className: 'Porn', probability: 0.8 }], ]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); }); - test('外部サービス: 通常の outbound agent を使用する', async () => { - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + test('外部サービス: HttpRequestService を使用する', async () => { + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' }); await svc.detectSensitiveMany([buf('a')]); - const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown }; - requestOptions.agent(new URL('https://detector.example.com/v1/detect-images')); - expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL)); + expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', { + method: 'POST', + headers: {}, + body: expect.any(FormData), + timeout: 5000, + }, { + throwErrorWhenResponseNotOk: false, + }); }); test('detectSensitive: 単一画像はバッチの先頭を返す', async () => { - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const svc = makeService(); const res = await svc.detectSensitive(buf('a')); expect(res).toEqual(neutral()); }); test('部分失敗: 失敗パーツのみ null になる', async () => { - fetchMock.mockResolvedValue(okResponse([ + sendMock.mockResolvedValue(okResponse([ { success: true, predictions: neutral() }, { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } }, ])); @@ -100,14 +97,14 @@ describe('AiService', () => { }); test('非200: チャンク全件 null(例外を投げない)', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); + sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); const svc = makeService(); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); expect(res).toEqual([null, null]); }); test('通信エラー: チャンク全件 null(例外を投げない)', async () => { - fetchMock.mockRejectedValue(new Error('network down')); + sendMock.mockRejectedValue(new Error('network down')); const svc = makeService(); const res = await svc.detectSensitiveMany([buf('a')]); expect(res).toEqual([null]); @@ -117,11 +114,11 @@ describe('AiService', () => { const svc = makeService({ sensitiveMediaDetectionApiUrl: null }); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); expect(res).toEqual([null, null]); - expect(fetchMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); }); test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => { - fetchMock.mockResolvedValue(okResponse([ + sendMock.mockResolvedValue(okResponse([ { success: true, predictions: neutral() }, { success: true, predictions: neutral() }, { success: true, predictions: neutral() }, @@ -130,22 +127,24 @@ describe('AiService', () => { const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 }); const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]); // 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。 - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sendMock).toHaveBeenCalledTimes(3); expect(res).toHaveLength(5); expect(res.every(x => x != null)).toBe(true); }); test('APIキー設定時のみ Authorization: Bearer を付与する', async () => { - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' }); await withKey.detectSensitiveMany([buf('a')]); - expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret'); + const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record }).headers; + expect(withKeyHeaders.Authorization).toBe('Bearer secret'); - fetchMock.mockClear(); - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + sendMock.mockClear(); + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const withoutKey = makeService(); await withoutKey.detectSensitiveMany([buf('a')]); - expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined(); + const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record }).headers; + expect(withoutKeyHeaders.Authorization).toBeUndefined(); }); }); diff --git a/packages/backend/test/unit/SentryTelemetryAdapter.ts b/packages/backend/test/unit/SentryTelemetryAdapter.ts deleted file mode 100644 index 4e66fc08e79..00000000000 --- a/packages/backend/test/unit/SentryTelemetryAdapter.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { describe, expect, test, vi } from 'vitest'; -import { buildSentryIntegrations } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js'; - -type TestIntegration = Parameters>[0][number]; - -function testIntegration(name: string): TestIntegration { - return { name }; -} - -describe('SentryTelemetryAdapter', () => { - test('removes disabled integrations from Sentry defaults', () => { - const integrations = buildSentryIntegrations({ - disabledIntegrations: ['Postgres'], - enableNodeProfiling: false, - }); - - const result = integrations([ - testIntegration('Http'), - testIntegration('Postgres'), - testIntegration('Redis'), - ]); - - expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']); - }); - - test('keeps profiling integration when enabled', () => { - const integrations = buildSentryIntegrations({ - disabledIntegrations: [], - enableNodeProfiling: true, - nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'), - }); - - const result = integrations([ - testIntegration('Http'), - ]); - - expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']); - }); - - test('warns about unknown disabled integration names without removing defaults', () => { - const warn = vi.fn(); - const integrations = buildSentryIntegrations({ - disabledIntegrations: ['Unknown'], - enableNodeProfiling: false, - warn, - }); - - const result = integrations([ - testIntegration('Http'), - ]); - - expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']); - expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown'); - }); -}); diff --git a/packages/backend/test/unit/boot/process-error-handler.ts b/packages/backend/test/unit/boot/process-error-handler.ts new file mode 100644 index 00000000000..32e341cc488 --- /dev/null +++ b/packages/backend/test/unit/boot/process-error-handler.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogWriteInput } from '@/logging/types.js'; +import { installProcessErrorHandlers } from '@/boot/process-error-handler.js'; + +type ProcessListener = (...args: unknown[]) => void; + +/** テスト用に、登録されたプロセスイベントを呼び出せる処理対象を作成します。 */ +function createProcessLike(): { + readonly process: { on: (event: string, listener: ProcessListener) => void }; + readonly listeners: Map; +} { + const listeners = new Map(); + return { + process: { + on: (event, listener) => listeners.set(event, listener), + }, + listeners, + }; +} + +describe('installProcessErrorHandlers', () => { + test('records unhandled rejections and uncaught exceptions as structured errors', () => { + const { process, listeners } = createProcessLike(); + const write = vi.fn<(input: LogWriteInput) => void>(); + const rejection = new Error('rejected'); + const exception = new TypeError('uncaught'); + + installProcessErrorHandlers({ process: process as never, logger: { write }, quiet: false }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + listeners.get('unhandledRejection')!(rejection); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + listeners.get('uncaughtException')!(exception); + + expect(write).toHaveBeenNthCalledWith(1, { + level: 'error', + eventName: 'process.unhandled_rejection', + message: 'Unhandled promise rejection', + error: rejection, + }); + expect(write).toHaveBeenNthCalledWith(2, { + level: 'error', + eventName: 'process.uncaught_exception', + message: 'Uncaught exception', + error: exception, + }); + }); + + test('does not register the unhandled rejection handler in quiet mode', () => { + const { process, listeners } = createProcessLike(); + + installProcessErrorHandlers({ process: process as never, logger: { write: vi.fn() }, quiet: true }); + + expect([...listeners.keys()]).toEqual(['uncaughtException']); + }); + + test('does not rethrow when the logger fails', () => { + const { process, listeners } = createProcessLike(); + const write = vi.fn(() => { + throw new Error('logger failed'); + }); + + installProcessErrorHandlers({ process: process as never, logger: { write }, quiet: false }); + + expect(() => listeners.get('unhandledRejection')!(new Error('rejected'))).not.toThrow(); + expect(() => listeners.get('uncaughtException')!(new Error('uncaught'))).not.toThrow(); + }); +}); diff --git a/packages/backend/test/unit/boot/shutdown-handler.ts b/packages/backend/test/unit/boot/shutdown-handler.ts new file mode 100644 index 00000000000..399820f08c9 --- /dev/null +++ b/packages/backend/test/unit/boot/shutdown-handler.ts @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; + +describe('shutdown-handler', () => { + test('runs shutdown tasks once and exits on SIGTERM or SIGINT', async () => { + vi.resetModules(); + const { installShutdownSignalHandlers, isShutdownInProgress } = await import('@/boot/shutdown-handler.js'); + const handlers = new Map Promise>(); + const processLike = { + once: vi.fn((event: string, handler: () => Promise) => { + handlers.set(event, handler); + return processLike; + }), + }; + const calls: string[] = []; + const shutdownTelemetry = vi.fn(async () => { + calls.push('telemetry'); + }); + const shutdownLogging = vi.fn(async () => { + calls.push('logging'); + }); + const exit = vi.fn(); + const onRegistered = vi.fn(); + + installShutdownSignalHandlers({ process: processLike, shutdownTasks: [shutdownTelemetry, shutdownLogging], exit, onRegistered }); + + expect(processLike.once).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processLike.once).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(onRegistered).toHaveBeenCalledOnce(); + expect(isShutdownInProgress()).toBe(false); + + await handlers.get('SIGTERM')!(); + await handlers.get('SIGINT')!(); + + expect(isShutdownInProgress()).toBe(true); + expect(calls).toEqual(['telemetry', 'logging']); + expect(shutdownTelemetry).toHaveBeenCalledTimes(1); + expect(shutdownLogging).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(0); + }); + + test('continues with later shutdown tasks when an earlier task rejects', async () => { + vi.resetModules(); + const { installShutdownSignalHandlers } = await import('@/boot/shutdown-handler.js'); + const handlers = new Map Promise>(); + const processLike = { + once: vi.fn((event: string, handler: () => Promise) => { + handlers.set(event, handler); + return processLike; + }), + }; + const exit = vi.fn(); + const shutdownLogging = vi.fn().mockResolvedValue(undefined); + const shutdownError = new Error('flush failed'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + installShutdownSignalHandlers({ + process: processLike, + shutdownTasks: [vi.fn().mockRejectedValue(shutdownError), shutdownLogging], + exit, + }); + + await handlers.get('SIGINT')!(); + + expect(exit).toHaveBeenCalledWith(0); + expect(shutdownLogging).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith('Shutdown task failed:', shutdownError); + } finally { + consoleError.mockRestore(); + } + }); + + test('exits after the shutdown deadline when a task remains pending', async () => { + vi.resetModules(); + vi.useFakeTimers(); + const { installShutdownSignalHandlers } = await import('@/boot/shutdown-handler.js'); + const handlers = new Map Promise>(); + const processLike = { + once: vi.fn((event: string, handler: () => Promise) => { + handlers.set(event, handler); + return processLike; + }), + }; + const exit = vi.fn(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + installShutdownSignalHandlers({ + process: processLike, + shutdownTasks: [() => new Promise(() => {})], + exit, + }); + + const signalPromise = handlers.get('SIGTERM')!(); + expect(exit).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(10_000); + await signalPromise; + + expect(consoleError).toHaveBeenCalledWith('Shutdown tasks timed out after 10000ms.'); + expect(exit).toHaveBeenCalledWith(0); + } finally { + consoleError.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts index 263f30520f8..cc8a0c39f0b 100644 --- a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts +++ b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -3,12 +3,57 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -process.env.NODE_ENV = 'test'; +import { describe, expect, test, vi } from 'vitest'; +import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js'; -import { describe, expect, test } from 'vitest'; -import { buildSentryNodeOptions } from '../../../../../src/core/telemetry/adapters/SentryTelemetryAdapter.js'; +type TestIntegration = Parameters>[0][number]; + +function testIntegration(name: string): TestIntegration { + return { name }; +} + +describe('SentryTelemetryAdapter', () => { + test('removes disabled integrations from Sentry defaults', () => { + const integrations = buildSentryIntegrations({ + disabledIntegrations: ['Postgres'], + enableNodeProfiling: false, + }); + + const result = integrations([ + testIntegration('Http'), + testIntegration('Postgres'), + testIntegration('Redis'), + ]); + + expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']); + }); + + test('keeps profiling integration when enabled', () => { + const integrations = buildSentryIntegrations({ + disabledIntegrations: [], + enableNodeProfiling: true, + nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'), + }); + + const result = integrations([testIntegration('Http')]); + + expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']); + }); + + test('warns about unknown disabled integration names without removing defaults', () => { + const warn = vi.fn(); + const integrations = buildSentryIntegrations({ + disabledIntegrations: ['Unknown'], + enableNodeProfiling: false, + warn, + }); + + const result = integrations([testIntegration('Http')]); + + expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']); + expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown'); + }); -describe('buildSentryNodeOptions', () => { test('disables outbound trace propagation by default', () => { const options = buildSentryNodeOptions({ enableNodeProfiling: false, @@ -29,3 +74,63 @@ describe('buildSentryNodeOptions', () => { expect(options.tracePropagationTargets).toEqual(['^https://internal\\.example/']); }); }); + +describe('SentryTelemetryAdapter trace context', () => { + test('returns the active span context for log enrichment', async () => { + const activeSpan = { + spanContext: () => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }), + }; + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + close: vi.fn(), + getActiveSpan: vi.fn(() => activeSpan), + })); + vi.doMock('@sentry/profiling-node', () => ({ + nodeProfilingIntegration: vi.fn(), + })); + + const adapter = await SentryTelemetryAdapter.create({ + enableNodeProfiling: false, + options: {}, + }); + + expect(adapter.getActiveTraceContext()).toEqual({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }); + + vi.doUnmock('@sentry/node'); + vi.doUnmock('@sentry/profiling-node'); + }); +}); + +describe('SentryTelemetryAdapter.shutdown', () => { + test('bounds Sentry.close() with a timeout so a stuck transport cannot hang process shutdown', async () => { + const close = vi.fn().mockResolvedValue(true); + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + close, + })); + vi.doMock('@sentry/profiling-node', () => ({ + nodeProfilingIntegration: vi.fn(), + })); + + const adapter = await SentryTelemetryAdapter.create({ + enableNodeProfiling: false, + options: {}, + }); + await adapter.shutdown(); + + expect(close).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(expect.any(Number)); + expect(close.mock.calls[0][0]).toBeGreaterThan(0); + + vi.doUnmock('@sentry/node'); + vi.doUnmock('@sentry/profiling-node'); + }); +}); diff --git a/packages/backend/test/unit/logging/BootstrapConsoleBackend.ts b/packages/backend/test/unit/logging/BootstrapConsoleBackend.ts new file mode 100644 index 00000000000..43545a0693e --- /dev/null +++ b/packages/backend/test/unit/logging/BootstrapConsoleBackend.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogRecord } from '@/logging/types.js'; +import { BootstrapConsoleBackend } from '@/logging/BootstrapConsoleBackend.js'; + +function createRecord(overrides: Partial = {}): LogRecord { + return { + level: 'error', + message: 'configuration failed', + context: [{ name: 'core' }, { name: 'boot' }], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'core.boot', + processId: 1234, + isPrimary: true, + workerId: null, + ...overrides, + }; +} + +describe('BootstrapConsoleBackend', () => { + test('writes a minimal line with legacy data', () => { + const output = vi.fn(); + const backend = new BootstrapConsoleBackend({ output }); + const data = { detail: 'failed' }; + + backend.write(createRecord({ compatibility: { data } })); + + expect(output).toHaveBeenCalledWith('2025-01-02T03:04:05.678Z ERROR *\t[core.boot]\tconfiguration failed', data); + }); + + test('writes structured details without depending on Pretty formatting', () => { + const output = vi.fn(); + const backend = new BootstrapConsoleBackend({ output }); + + backend.write(createRecord({ + eventName: 'config.load.failed', + attributes: { path: '.config/default.yml' }, + error: { type: 'Error', message: 'not found' }, + })); + + expect(output).toHaveBeenCalledWith( + '2025-01-02T03:04:05.678Z ERROR *\t[core.boot]\tconfiguration failed', + { + eventName: 'config.load.failed', + attributes: { path: '.config/default.yml' }, + error: { type: 'Error', message: 'not found' }, + }, + ); + }); +}); diff --git a/packages/backend/test/unit/logging/JsonConsoleBackend.ts b/packages/backend/test/unit/logging/JsonConsoleBackend.ts new file mode 100644 index 00000000000..67ca71fcc3d --- /dev/null +++ b/packages/backend/test/unit/logging/JsonConsoleBackend.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { AccessLogRecord, LogRecord } from '@/logging/types.js'; +import { JsonConsoleBackend } from '@/logging/JsonConsoleBackend.js'; + +/** JSON形式のテストで使う共通のログを作成します。 */ +function createRecord(overrides: Partial = {}): LogRecord { + return { + level: 'error', + message: 'delivery failed', + context: [{ name: 'queue' }, { name: 'deliver' }], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'queue.deliver', + processId: 1234, + isPrimary: false, + workerId: 7, + ...overrides, + }; +} + +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 }); + + backend.write(createRecord({ + eventName: 'queue.job.failed', + attributes: { jobId: '123', attempt: 2 }, + error: { type: 'TypeError', message: 'broken', stack: 'stack' }, + })); + + expect(output).toHaveBeenCalledOnce(); + expect(output.mock.calls[0][0]).toMatchInlineSnapshot(`"{\"timestamp\":\"2025-01-02T03:04:05.678Z\",\"level\":\"error\",\"message\":\"delivery failed\",\"loggerName\":\"queue.deliver\",\"eventName\":\"queue.job.failed\",\"attributes\":{\"jobId\":\"123\",\"attempt\":2},\"error\":{\"type\":\"TypeError\",\"message\":\"broken\",\"stack\":\"stack\"},\"processId\":1234,\"isPrimary\":false,\"workerId\":7}"`); + }); + + test('omits pretty-only compatibility data and context colors', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + const record = createRecord({ + context: [{ name: 'queue', color: 'red' }], + compatibility: { + legacyLevel: 'success', + important: true, + data: { secret: 'must not be written' }, + }, + }); + + backend.write(record); + + expect(JSON.parse(output.mock.calls[0][0])).toEqual({ + timestamp: '2025-01-02T03:04:05.678Z', + level: 'error', + message: 'delivery failed', + loggerName: 'queue.deliver', + processId: 1234, + isPrimary: false, + workerId: 7, + }); + }); + + test('writes trace context using the standard JSON field names', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + + backend.write(createRecord({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + })); + + expect(JSON.parse(output.mock.calls[0][0])).toMatchObject({ + trace_id: '0123456789abcdef0123456789abcdef', + span_id: '0123456789abcdef', + trace_flags: 0, + }); + }); + + test('escapes newlines so each record remains one physical line', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + const message = 'first line\nsecond line "quoted"'; + + backend.write(createRecord({ message })); + + const line = output.mock.calls[0][0]; + expect(line).not.toContain('\n'); + expect(JSON.parse(line).message).toBe(message); + }); +}); diff --git a/packages/backend/test/unit/logging/LogManager.ts b/packages/backend/test/unit/logging/LogManager.ts new file mode 100644 index 00000000000..94880856a1a --- /dev/null +++ b/packages/backend/test/unit/logging/LogManager.ts @@ -0,0 +1,412 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { LogBackend } from '@/logging/LogBackend.js'; +import type { AccessLogRecord, AccessLogRecordInput, LogRecordInput, LogTraceContext } from '@/logging/types.js'; +import { LogManager } from '@/logging/LogManager.js'; + +/** テストで使う最小構成のログ入力を作成します。 */ +function createInput(level: LogRecordInput['level'] = 'info'): LogRecordInput { + return { + level, + message: 'message', + context: [ + { name: 'queue', color: 'red' }, + { name: 'deliver', color: 'blue' }, + ], + }; +} + +/** 実行環境を固定したLogManagerと、出力確認用の関数を作成します。 */ +function createManager(options: { + quiet?: boolean; + verbose?: boolean; + nodeEnv?: string; + isPrimary?: boolean; + workerId?: number | null; + normalizationProfile?: 'standard' | 'detailed'; + configuration?: { + level?: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off'; + domains?: Record; + access?: { + statusClasses?: ('2xx' | '3xx' | '4xx' | '5xx')[]; + bodies?: { request?: boolean; response?: boolean; maxBytes?: number }; + }; + }; +} = {}) { + const write = vi.fn(); + 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, + isPrimary: options.isPrimary ?? true, + workerId: options.workerId ?? null, + }), + isQuiet: () => options.quiet ?? false, + isVerbose: () => options.verbose ?? false, + getNodeEnv: () => options.nodeEnv ?? 'development', + }, { + normalizationProfile: options.normalizationProfile, + }); + if (options.configuration) manager.configure(options.configuration); + + return { manager, write, writeAccess }; +} + +describe('LogManager', () => { + test('adds logger and process metadata while preserving root-to-leaf context order', () => { + const { manager, write } = createManager(); + const input = createInput(); + + manager.write(input); + + expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith({ + ...input, + context: [ + { name: 'queue', color: 'red' }, + { name: 'deliver', color: 'blue' }, + ], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'queue.deliver', + processId: 1234, + isPrimary: true, + workerId: null, + }); + expect(write.mock.calls[0][0].context).not.toBe(input.context); + }); + + test('records worker process metadata', () => { + const { manager, write } = createManager({ isPrimary: false, workerId: 7 }); + + manager.write(createInput()); + + expect(write.mock.calls[0][0]).toMatchObject({ + processId: 1234, + isPrimary: false, + workerId: 7, + }); + }); + + test('adds active trace context to the record after filtering', () => { + const { manager, write } = createManager(); + const traceContext: LogTraceContext = { + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }; + const provider = vi.fn(() => traceContext); + manager.setTraceContextProvider(provider); + + manager.write(createInput()); + + expect(provider).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0]).toMatchObject(traceContext); + }); + + test('does not get trace context for logs that will not be written', () => { + const provider = vi.fn(() => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 1, + })); + const filtered = createManager({ configuration: { level: 'warn' } }); + filtered.manager.setTraceContextProvider(provider); + filtered.manager.write(createInput('info')); + + const quiet = createManager({ quiet: true }); + quiet.manager.setTraceContextProvider(provider); + quiet.manager.write(createInput('error')); + + expect(provider).not.toHaveBeenCalled(); + }); + + test('does not call the backend in quiet mode', () => { + const { manager, write } = createManager({ quiet: true, verbose: true }); + + manager.write(createInput('fatal')); + manager.write(createInput('debug')); + + expect(write).not.toHaveBeenCalled(); + }); + + test('writes debug logs outside production', () => { + const { manager, write } = createManager({ nodeEnv: 'development' }); + + manager.write(createInput('debug')); + + expect(write).toHaveBeenCalledOnce(); + }); + + test('suppresses debug logs in production by default', () => { + const { manager, write } = createManager({ nodeEnv: 'production' }); + + manager.write(createInput('debug')); + + expect(write).not.toHaveBeenCalled(); + }); + + test('writes debug logs in verbose production mode', () => { + const { manager, write } = createManager({ nodeEnv: 'production', verbose: true }); + + manager.write(createInput('debug')); + + expect(write).toHaveBeenCalledOnce(); + }); + + test('applies the configured global level', () => { + const { manager, write } = createManager({ configuration: { level: 'warn' } }); + + manager.write(createInput('info')); + manager.write(createInput('warn')); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0].level).toBe('warn'); + }); + + test('uses the longest matching domain and supports child re-enablement', () => { + const { manager, write } = createManager({ + configuration: { + level: 'info', + domains: { + queue: 'off', + 'queue.deliver': 'debug', + }, + }, + }); + + manager.write({ ...createInput('info'), context: [{ name: 'queue' }, { name: 'inbox' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queue' }, { name: 'deliver' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queueing' }] }); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0].loggerName).toBe('queue.deliver'); + }); + + test('lowers configured levels to debug in verbose mode', () => { + const { manager, write } = createManager({ + nodeEnv: 'production', + verbose: true, + configuration: { level: 'info' }, + }); + + manager.write(createInput('debug')); + + expect(write).toHaveBeenCalledOnce(); + }); + + test('keeps explicit off levels disabled in verbose mode', () => { + const { manager, write } = createManager({ + verbose: true, + configuration: { + level: 'off', + domains: { + queue: 'off', + 'queue.deliver': 'warn', + }, + }, + }); + + manager.write({ ...createInput('fatal'), context: [{ name: 'system' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queue' }] }); + manager.write({ ...createInput('debug'), context: [{ name: 'queue' }, { name: 'deliver' }] }); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0].loggerName).toBe('queue.deliver'); + }); + + test('rejects invalid logging configuration', () => { + const { manager } = createManager(); + + expect(() => manager.configure({ domains: null })).not.toThrow(); + 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', () => { + const { manager, write } = createManager(); + const replacementWrite = vi.fn(); + + manager.setBackend({ write: replacementWrite }); + manager.write(createInput()); + + expect(write).not.toHaveBeenCalled(); + expect(replacementWrite).toHaveBeenCalledOnce(); + }); + + test('flushes and closes the backend once during shutdown', async () => { + const write = vi.fn(); + const flush = vi.fn>().mockResolvedValue(undefined); + const close = vi.fn>().mockResolvedValue(undefined); + const manager = new LogManager({ write, flush, close }); + + await Promise.all([manager.shutdown(), manager.shutdown()]); + + expect(flush).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(flush.mock.invocationCallOrder[0]).toBeLessThan(close.mock.invocationCallOrder[0]); + }); + + test('normalizes structured attributes and errors before writing', () => { + const { manager, write } = createManager(); + const error = new TypeError('broken'); + + manager.write({ + ...createInput('error'), + eventName: 'api.endpoint.failed', + attributes: { i: 'secret', safe: 'value' }, + error, + }); + + expect(write.mock.calls[0][0]).toMatchObject({ + eventName: 'api.endpoint.failed', + attributes: { i: '[REDACTED]', safe: 'value' }, + error: { type: 'TypeError', message: 'broken' }, + }); + }); + + test('does not pass raw structured values when normalization omits an error', () => { + const { manager, write } = createManager(); + + manager.write({ + ...createInput('error'), + attributes: { detail: 'value' }, + error: null, + }); + + expect(write.mock.calls[0][0].attributes).toEqual({ detail: 'value' }); + expect(write.mock.calls[0][0]).not.toHaveProperty('error'); + }); + + test('keeps legacy data for the pretty output while serializing its Error separately', () => { + const { manager, write } = createManager(); + const error = new Error('legacy failure'); + const data = { detail: 'legacy', e: error }; + + manager.write({ + ...createInput('error'), + compatibility: { data }, + }); + + expect(write.mock.calls[0][0].compatibility?.data).toBe(data); + expect(write.mock.calls[0][0]).toMatchObject({ + error: { type: 'Error', message: 'legacy failure' }, + }); + expect(write.mock.calls[0][0].attributes).toBeUndefined(); + }); + + test('supports the detailed normalization profile', () => { + const { manager, write } = createManager({ normalizationProfile: 'detailed' }); + + manager.write({ + ...createInput(), + attributes: { nested: { level1: { level2: { level3: { value: 'kept' } } } } }, + }); + + expect(write.mock.calls[0][0].attributes).toEqual({ + nested: { level1: { level2: { level3: { value: 'kept' } } } }, + }); + }); + + test('switches the normalization profile for existing loggers', () => { + const { manager, write } = createManager(); + const nested = { level1: { level2: { level3: { level4: { level5: { level6: { value: 'kept' } } } } } } }; + + manager.write({ ...createInput(), attributes: nested }); + manager.setNormalizationProfile('detailed'); + manager.write({ ...createInput(), attributes: nested }); + + expect(write.mock.calls[0][0].attributes).toMatchObject({ + level1: { level2: { level3: { level4: { level5: { level6: '[Truncated]' } } } } }, + }); + expect(write.mock.calls[1][0].attributes).toEqual(nested); + }); +}); diff --git a/packages/backend/test/unit/logging/LogNormalizer.ts b/packages/backend/test/unit/logging/LogNormalizer.ts new file mode 100644 index 00000000000..b183f03fcd2 --- /dev/null +++ b/packages/backend/test/unit/logging/LogNormalizer.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + findLegacyLogError, + normalizeLogAttributes, + normalizeLogValue, + serializeLogError, +} from '@/logging/LogNormalizer.js'; + +describe('LogNormalizer', () => { + test('normalizes common non-JSON values', () => { + expect(normalizeLogAttributes({ + date: new Date('2025-01-02T03:04:05.678Z'), + bigint: 123n, + infinity: Infinity, + undefinedValue: undefined, + })).toEqual({ + bigint: '123', + date: '2025-01-02T03:04:05.678Z', + infinity: 'Infinity', + undefinedValue: '[Unsupported]: undefined', + }); + }); + + 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']]), + invalidDate: new Date('invalid'), + })).toEqual({ + invalidDate: '[Unsupported]: object access failed', + map: '[Unsupported]: object', + }); + }); + + test('preserves __proto__ as a normal attribute key', () => { + const value = JSON.parse('{"__proto__":{"nested":"value"},"large":"' + 'x'.repeat(100) + '"}') as Record; + const normalized = normalizeLogAttributes(value, { limits: { maxBytes: 64 } }); + + expect(Object.keys(normalized)).toContain('__proto__'); + expect(normalized['__proto__']).toEqual({ nested: 'value' }); + expect(Object.getPrototypeOf(normalized)).toBeNull(); + }); + + test('redacts sensitive fields recursively, including the Misskey i token', () => { + expect(normalizeLogAttributes({ + i: 'top-level-token', + request: { + Authorization: 'bearer-token', + captcha: 'captcha-value', + password: 'password', + nested: [{ api_key: 'api-key' }], + }, + visible: 'value', + })).toEqual({ + i: '[REDACTED]', + request: { + Authorization: '[REDACTED]', + captcha: '[REDACTED]', + password: '[REDACTED]', + nested: [{ api_key: '[REDACTED]' }], + }, + visible: 'value', + }); + }); + + test('keeps cycles finite and marks depth and entry truncation', () => { + const cycle: Record = { value: 'ok' }; + cycle.self = cycle; + + expect(normalizeLogAttributes({ + cycle, + deep: { level1: { level2: { level3: 'value' } } }, + }, { + limits: { maxDepth: 2, maxEntries: 10 }, + })).toEqual({ + cycle: { self: '[Circular]', value: 'ok' }, + deep: { level1: '[Truncated]' }, + }); + expect(normalizeLogAttributes({ entries: { a: 1, b: 2, c: 3 } }, { limits: { maxEntries: 2 } })).toEqual({ + entries: { a: 1, b: 2, '[Truncated]': '[Truncated]' }, + }); + }); + + test('uses the detailed profile while retaining the redaction policy', () => { + const value = { level1: { level2: { level3: { level4: 'value' } } }, token: 'secret' }; + + expect(normalizeLogAttributes(value, { limits: { maxDepth: 3 } })).toMatchObject({ level1: { level2: { level3: '[Truncated]' } }, token: '[REDACTED]' }); + expect(normalizeLogAttributes(value, { profile: 'detailed' })).toEqual({ + level1: { level2: { level3: { level4: 'value' } } }, + token: '[REDACTED]', + }); + }); + + test('keeps normalized attributes within the configured byte limit', () => { + const normalized = normalizeLogAttributes({ first: 'a'.repeat(100), second: 'b'.repeat(100) }, { limits: { maxBytes: 32 } }); + + expect(Buffer.byteLength(JSON.stringify(normalized), 'utf8')).toBeLessThanOrEqual(32); + }); + + test('truncates long multibyte strings without splitting characters', () => { + const normalized = normalizeLogAttributes({ value: '😀'.repeat(100) }, { limits: { maxStringBytes: 16, maxBytes: 100 } }); + + expect(Buffer.byteLength(JSON.stringify(normalized.value), 'utf8')).toBeLessThanOrEqual(16); + expect(JSON.stringify(normalized.value)).not.toContain('�'); + }); + + test('serializes Error and its cause consistently', () => { + const cause = new Error('root cause'); + const error = new TypeError('outer error', { cause }); + + expect(serializeLogError(error)).toMatchObject({ + type: 'TypeError', + message: 'outer error', + cause: { + type: 'Error', + message: 'root cause', + }, + }); + }); + + test('keeps serialized Error output within its byte limit', () => { + const serialized = serializeLogError(new Error('a long message'), { limits: { maxBytes: 32 } }); + + expect(serialized).toBeDefined(); + expect(Buffer.byteLength(JSON.stringify(serialized), 'utf8')).toBeLessThanOrEqual(32); + expect(serializeLogError(new Error('message'), { limits: { maxBytes: 20 } })).toBeUndefined(); + }); + + test('finds Error values in legacy data', () => { + const error = new Error('legacy'); + + expect(findLegacyLogError({ e: error })).toBe(error); + expect(findLegacyLogError({ stack: 'not-an-error' })).toBeUndefined(); + }); +}); diff --git a/packages/backend/test/unit/logging/Logger.ts b/packages/backend/test/unit/logging/Logger.ts new file mode 100644 index 00000000000..dca5cab7735 --- /dev/null +++ b/packages/backend/test/unit/logging/Logger.ts @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { LogRecordInput } from '@/logging/types.js'; + +const mocks = vi.hoisted(() => ({ + write: vi.fn<(input: LogRecordInput) => void>(), +})); + +vi.mock('@/logging/logging-runtime.js', () => ({ + logManager: { + write: mocks.write, + }, +})); + +import Logger from '@/logger.js'; + +describe('Logger', () => { + beforeEach(() => { + mocks.write.mockReset(); + }); + + test('passes immutable multi-level root-to-leaf context to the manager', () => { + const root = new Logger('root', 'red'); + const child = root.createSubLogger('child', 'green'); + const leaf = child.createSubLogger('leaf', 'blue'); + + leaf.info('from leaf'); + root.info('from root'); + + expect(mocks.write.mock.calls[0][0]).toMatchObject({ + level: 'info', + message: 'from leaf', + context: [ + { name: 'root', color: 'red' }, + { name: 'child', color: 'green' }, + { name: 'leaf', color: 'blue' }, + ], + }); + expect(mocks.write.mock.calls[1][0].context).toEqual([ + { name: 'root', color: 'red' }, + ]); + }); + + test('maps succ to info with the legacy success presentation', () => { + new Logger('root').succ('completed', { count: 1 }, true); + + expect(mocks.write).toHaveBeenCalledWith({ + level: 'info', + message: 'completed', + context: [{ name: 'root', color: undefined }], + compatibility: { + legacyLevel: 'success', + important: true, + data: { count: 1 }, + }, + }); + }); + + test('supports structured log input while preserving the context hierarchy', () => { + new Logger('root').createSubLogger('child').write({ + level: 'error', + eventName: 'example.failed', + message: 'failed', + attributes: { id: 'id' }, + error: new Error('broken'), + }); + + expect(mocks.write).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + eventName: 'example.failed', + context: [{ name: 'root', color: undefined }, { name: 'child', color: undefined }], + })); + }); + + 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'); + const data: Record = { requestId: 'request' }; + + logger.error(error, data, true); + + expect(data).toEqual({ requestId: 'request', e: error }); + expect(mocks.write).toHaveBeenCalledWith({ + level: 'error', + message: 'TypeError: broken', + context: [{ name: 'root', color: undefined }], + error, + compatibility: { + legacyLevel: undefined, + important: true, + data, + }, + }); + }); + + test('creates data containing the Error when none is supplied', () => { + const error = new Error('broken'); + + new Logger('root').error(error); + + expect(mocks.write.mock.calls[0][0].compatibility?.data).toEqual({ e: error }); + }); + + test('keeps public methods bound when called separately', () => { + const logger = new Logger('root'); + const info = logger.info; + + info('bound'); + + expect(mocks.write).toHaveBeenCalledWith(expect.objectContaining({ + level: 'info', + message: 'bound', + context: [{ name: 'root', color: undefined }], + })); + }); +}); diff --git a/packages/backend/test/unit/logging/PrettyConsoleBackend.ts b/packages/backend/test/unit/logging/PrettyConsoleBackend.ts new file mode 100644 index 00000000000..004c60f783c --- /dev/null +++ b/packages/backend/test/unit/logging/PrettyConsoleBackend.ts @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import type { AccessLogRecord, LogRecord } from '@/logging/types.js'; + +type Formatter = ((value: unknown) => string) & { white?: Formatter }; + +const chalkMock = vi.hoisted(() => { + const format = (name: string): Formatter => (value: unknown) => `<${name}>${String(value)}`; + const bgRed = format('bgRed'); + bgRed.white = format('bgRed.white'); + const bgGreen = format('bgGreen'); + bgGreen.white = format('bgGreen.white'); + + return { + red: format('red'), + yellow: format('yellow'), + green: format('green'), + gray: format('gray'), + blue: format('blue'), + white: format('white'), + bold: format('bold'), + bgRed, + bgGreen, + rgb: (red: number, green: number, blue: number) => format(`rgb:${red},${green},${blue}`), + }; +}); + +vi.mock('chalk', () => ({ + default: chalkMock, +})); + +import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; + +/** 見やすい形式のテストで使う共通のログを作成します。 */ +function createRecord(overrides: Partial = {}): LogRecord { + return { + level: 'info', + message: 'message', + context: [{ name: 'root' }], + timestamp: '2025-01-02T03:04:05.678Z', + loggerName: 'root', + processId: 1234, + isPrimary: true, + workerId: null, + ...overrides, + }; +} + +/** 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: 'ERR ', message: 'message' }, + { level: 'warn', label: 'WARN', message: 'message' }, + { level: 'debug', label: 'VERB', message: 'message' }, + { level: 'info', label: 'INFO', message: 'message' }, + ] as const)('formats the $level label and message', ({ level, label, message }) => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ level })); + + expect(output).toHaveBeenCalledWith(`${label} *\t[root]\t${message}`); + }); + + test('formats legacy success as DONE while retaining the info severity', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + level: 'info', + compatibility: { legacyLevel: 'success' }, + })); + + expect(output).toHaveBeenCalledWith('DONE *\t[root]\tmessage'); + }); + + test('formats contexts from root to leaf using their configured colors', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + context: [ + { name: 'root', color: 'red' }, + { name: 'child' }, + { name: 'leaf', color: 'blue' }, + ], + })); + + expect(output).toHaveBeenCalledWith('INFO *\t[root child leaf]\tmessage'); + }); + + test('prefixes the time derived from the record timestamp when enabled', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => true }); + + backend.write(createRecord()); + + expect(output).toHaveBeenCalledWith('03:04:05 INFO *\t[root]\tmessage'); + }); + + test('uses the worker id for a worker process', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ isPrimary: false, workerId: 7 })); + + expect(output).toHaveBeenCalledWith('INFO 7\t[root]\tmessage'); + }); + + test('bolds an important record and passes data as the second output argument', () => { + const output = vi.fn(); + const data = { detail: 'value' }; + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + level: 'error', + compatibility: { important: true, data }, + })); + + expect(output).toHaveBeenCalledWith( + 'ERR *\t[root]\tmessage', + data, + ); + }); + + test('treats fatal records as important errors', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ level: 'fatal' })); + + expect(output).toHaveBeenCalledWith('ERR *\t[root]\tmessage'); + }); + + test('omits null data from output arguments', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ compatibility: { data: null } })); + + expect(output).toHaveBeenCalledTimes(1); + expect(output.mock.calls[0]).toHaveLength(1); + }); + + test('passes structured event details as normalized output data', () => { + const output = vi.fn(); + const backend = new PrettyConsoleBackend({ output, withLogTime: () => false }); + + backend.write(createRecord({ + eventName: 'api.endpoint.failed', + attributes: { 'api.endpoint': 'notes/show' }, + error: { type: 'TypeError', message: 'broken' }, + })); + + expect(output).toHaveBeenCalledWith( + 'INFO *\t[root]\tmessage', + { + eventName: 'api.endpoint.failed', + attributes: { 'api.endpoint': 'notes/show' }, + error: { type: 'TypeError', message: 'broken' }, + }, + ); + }); + + 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; + nested.visible = 'value'; + const requestBody = Object.create(null) as Record; + 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 }; + 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); + }); +}); diff --git a/packages/backend/test/unit/logging/logging-runtime.ts b/packages/backend/test/unit/logging/logging-runtime.ts new file mode 100644 index 00000000000..9e8b2c2903a --- /dev/null +++ b/packages/backend/test/unit/logging/logging-runtime.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { JsonConsoleBackend } from '@/logging/JsonConsoleBackend.js'; +import { logManager, configureLogging } from '@/logging/logging-runtime.js'; +import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; + +describe('logging-runtime', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('uses Pretty backend when the format is omitted', () => { + const setBackend = vi.spyOn(logManager, 'setBackend'); + + configureLogging(); + + expect(setBackend).toHaveBeenCalledWith(expect.any(PrettyConsoleBackend)); + }); + + test('uses JSON backend when the format is json', () => { + const setBackend = vi.spyOn(logManager, 'setBackend'); + + configureLogging({ format: 'json' }); + + expect(setBackend).toHaveBeenCalledWith(expect.any(JsonConsoleBackend)); + }); + + test('rejects an unknown format before changing logging configuration', () => { + const setBackend = vi.spyOn(logManager, 'setBackend'); + const configure = vi.spyOn(logManager, 'configure'); + + expect(() => configureLogging({ format: 'xml' as never })).toThrow('logging.format'); + expect(setBackend).not.toHaveBeenCalled(); + expect(configure).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/test/unit/misc/cache.ts b/packages/backend/test/unit/misc/cache.ts index 297378304e2..80085cf1480 100644 --- a/packages/backend/test/unit/misc/cache.ts +++ b/packages/backend/test/unit/misc/cache.ts @@ -38,6 +38,29 @@ describe('misc:MemoryKVCache', () => { cache.dispose(); }); + test('keeps current behavior when limit is omitted', () => { + const cache = new MemoryKVCache(1000 * 60); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBe(2); + expect(cache.get('c')).toBe(3); + cache.dispose(); + }); + + test('evicts least recently used entry when limit is reached', () => { + const cache = new MemoryKVCache(1000 * 60, 2); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.get('a')).toBe(1); + cache.set('c', 3); + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('c')).toBe(3); + cache.dispose(); + }); + describe('gc()', () => { test('removes expired entries', () => { const cache = new MemoryKVCache(1000); diff --git a/packages/backend/test/unit/queue/queue-job-runner.ts b/packages/backend/test/unit/queue/queue-job-runner.ts new file mode 100644 index 00000000000..72c4a77ef28 --- /dev/null +++ b/packages/backend/test/unit/queue/queue-job-runner.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { runQueueJob } from '@/queue/queue-job-runner.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; + +describe('runQueueJob', () => { + test('returns the processor result without invoking the error handler', async () => { + let spanActive = false; + const startSpan = vi.fn((_name: string, fn: () => T): T => { + spanActive = true; + const result = fn(); + if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T; + spanActive = false; + return result; + }); + const telemetryService = { + startSpan, + } as unknown as TelemetryService; + const onError = vi.fn(); + + await expect(runQueueJob(telemetryService, 'Queue: test', () => 'ok', onError)).resolves.toBe('ok'); + + expect(onError).not.toHaveBeenCalled(); + expect(spanActive).toBe(false); + }); + + test('handles failures while the processor span is active and rethrows the original error', async () => { + let spanActive = false; + const startSpan = vi.fn((_name: string, fn: () => T): T => { + spanActive = true; + const result = fn(); + if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T; + spanActive = false; + return result; + }); + const telemetryService = { + startSpan, + } as unknown as TelemetryService; + const onError = vi.fn((error: Error) => { + expect(spanActive).toBe(true); + expect(error).toBeInstanceOf(Error); + }); + const originalError = new Error('failed'); + + await expect(runQueueJob(telemetryService, 'Queue: test', async () => { + throw originalError; + }, onError)).rejects.toBe(originalError); + + expect(onError).toHaveBeenCalledOnce(); + expect(spanActive).toBe(false); + }); +}); diff --git a/packages/backend/test/unit/server/ApiCallService.ts b/packages/backend/test/unit/server/ApiCallService.ts new file mode 100644 index 00000000000..ca0d00d6517 --- /dev/null +++ b/packages/backend/test/unit/server/ApiCallService.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { ApiCallService } from '@/server/api/ApiCallService.js'; +import Logger from '@/logger.js'; +import { envOption } from '@/env.js'; +import { logManager } from '@/logging/logging-runtime.js'; +import { PrettyConsoleBackend } from '@/logging/PrettyConsoleBackend.js'; +import type { LogBackend } from '@/logging/LogBackend.js'; +import type { LogRecord } from '@/logging/types.js'; + +/** API失敗ログを確認するための最小Fastify応答を作成します。 */ +function createReply() { + return { + code: vi.fn(), + header: vi.fn(), + send: vi.fn(), + }; +} + +/** APIサービスの依存関係を最小限の仮実装へ差し替えます。 */ +function createService() { + const authenticateService = { + authenticate: vi.fn().mockResolvedValue([null, null]), + }; + const telemetryService = { + startSpan: vi.fn((_name: string, callback: () => unknown) => callback()), + captureMessage: vi.fn(), + }; + const apiLoggerService = { logger: new Logger('api') }; + + const service = new ApiCallService( + {} as never, + {} as never, + {} as never, + authenticateService as never, + {} as never, + {} as never, + apiLoggerService as never, + telemetryService as never, + ); + return { service, telemetryService }; +} + +describe('ApiCallService structured error logging', () => { + test('passes the Fastify request to endpoint executors', async () => { + const { service } = createService(); + try { + const reply = createReply(); + const endpoint = { + name: 'drive/files/upload-commit', + meta: {}, + params: {}, + exec: vi.fn().mockResolvedValue({}), + }; + const request = { + method: 'POST', + body: {}, + query: {}, + headers: {}, + ip: '127.0.0.1', + raw: {}, + }; + + await service.handleRequest(endpoint as never, request as never, reply as never); + + expect(endpoint.exec).toHaveBeenCalledWith({}, null, null, null, '127.0.0.1', {}, request); + } finally { + service.dispose(); + } + }); + + test('redacts API credentials and serializes the endpoint error', async () => { + const write = vi.fn(); + logManager.setBackend({ write }); + const previousQuiet = envOption.quiet; + envOption.quiet = false; + const { service, telemetryService } = createService(); + try { + const reply = createReply(); + const endpoint = { + name: 'notes/show', + meta: {}, + params: {}, + exec: vi.fn().mockRejectedValue(new TypeError('broken endpoint')), + }; + const request = { + method: 'POST', + body: { + i: 'native-token', + password: 'password', + options: { visible: true }, + }, + query: {}, + headers: {}, + ip: '127.0.0.1', + }; + + await service.handleRequest(endpoint as never, request as never, reply as never); + + const record = write.mock.calls[0][0] as LogRecord; + expect(record).toMatchObject({ + eventName: 'api.endpoint.failed', + attributes: { + 'api.endpoint': 'notes/show', + 'api.params': { + i: '[REDACTED]', + password: '[REDACTED]', + options: { visible: true }, + }, + }, + error: { type: 'TypeError', message: 'broken endpoint' }, + }); + expect(record.attributes?.['error.id']).toEqual(expect.any(String)); + expect(telemetryService.captureMessage.mock.calls[0][1].extra).not.toHaveProperty('ps'); + } finally { + service.dispose(); + envOption.quiet = previousQuiet; + logManager.setBackend(new PrettyConsoleBackend({ output: () => undefined })); + } + }); +}); diff --git a/packages/backend/test/unit/server/FileServerService.ts b/packages/backend/test/unit/server/FileServerService.ts index 66843cc4c12..9a8ed97fcb4 100644 --- a/packages/backend/test/unit/server/FileServerService.ts +++ b/packages/backend/test/unit/server/FileServerService.ts @@ -11,7 +11,7 @@ import { describe, expect, test, beforeAll, afterAll, afterEach } from 'vitest'; import sharp from 'sharp'; import { DataSource, type Repository } from 'typeorm'; import { initTestDb, randomString } from '../../utils.js'; -import type { AiService } from '@/core/AiService.js'; +import type { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { FileInfoService } from '@/core/FileInfoService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -147,11 +147,11 @@ describe('FileServerService', () => { driveFilesRepository = db.getRepository(MiDriveFile); const loggerService = new LoggerService(); - const aiService = { + const sensitiveMediaDetectionService = { detectSensitive: async () => null, detectSensitiveMany: async (sources: Buffer[]) => sources.map(() => null), - } as unknown as AiService; - const fileInfoService = new FileInfoService(aiService, loggerService); + } as unknown as SensitiveMediaDetectionService; + const fileInfoService = new FileInfoService(sensitiveMediaDetectionService, loggerService); const httpRequestService = new HttpRequestService(config); const downloadService = new DownloadService(config, httpRequestService, loggerService); const imageProcessingService = new ImageProcessingService(); diff --git a/packages/backend/test/unit/server/api/endpoints/fetch-rss.ts b/packages/backend/test/unit/server/api/endpoints/fetch-rss.ts new file mode 100644 index 00000000000..186b785d9b6 --- /dev/null +++ b/packages/backend/test/unit/server/api/endpoints/fetch-rss.ts @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import FetchRssEndpoint, { meta } from '@/server/api/endpoints/fetch-rss.js'; +import { ApiError } from '@/server/api/error.js'; +import type { Mocked } from 'vitest'; +import type { Response } from 'node-fetch'; + +const rssParserMocks = vi.hoisted(() => ({ + constructor: vi.fn(), + parseString: vi.fn(), +})); + +vi.mock('rss-parser', () => ({ + default: class { + constructor(options: unknown) { + rssParserMocks.constructor(options); + } + + public parseString(input: string) { + return rssParserMocks.parseString(input); + } + }, +})); + +const RSS = 'Test'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function response(url: string, text = RSS): Response { + return { + url, + text: vi.fn().mockResolvedValue(text), + } as unknown as Response; +} + +describe('fetch-rss endpoint', () => { + let httpRequestService: Mocked; + let endpoint: FetchRssEndpoint; + + beforeEach(() => { + rssParserMocks.constructor.mockReset(); + rssParserMocks.parseString.mockReset(); + rssParserMocks.parseString.mockResolvedValue({ items: [] }); + httpRequestService = { + send: vi.fn(), + } as unknown as Mocked; + endpoint = new FetchRssEndpoint(httpRequestService); + }); + + async function exec(url: string) { + return await endpoint.exec({ url }, null, null); + } + + async function expectApiError(promise: Promise, code: string, status: number) { + await expect(promise).rejects.toMatchObject({ + code, + httpStatusCode: status, + info: undefined, + }); + } + + test.each([ + '', + 'not a URL', + 'ftp://example.com/feed.xml', + `https://example.com/${'a'.repeat(8192)}`, + ])('rejects invalid URL: %s', async (url) => { + await expectApiError(exec(url), 'INVALID_URL', 400); + expect(httpRequestService.send).not.toHaveBeenCalled(); + }); + + test.each([ + 'https://user@example.com/feed.xml', + 'https://user:password@example.com/feed.xml', + ])('rejects URL containing credentials: %s', async (url) => { + await expectApiError(exec(url), 'INVALID_URL', 400); + expect(httpRequestService.send).not.toHaveBeenCalled(); + }); + + test('does not expose details from internal errors', async () => { + httpRequestService.send.mockRejectedValue(new Error('secret upstream details')); + + const promise = exec('https://example.com/feed.xml'); + await expectApiError(promise, 'FETCH_RSS_FAILED', 422); + await expect(promise).rejects.not.toMatchObject({ + message: expect.stringContaining('secret upstream details'), + }); + }); + + test('passes the 1 MiB response limit to HttpRequestService', async () => { + httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml')); + + await exec('https://example.com/feed.xml'); + + expect(httpRequestService.send).toHaveBeenCalledWith('https://example.com/feed.xml', expect.objectContaining({ + size: 1024 * 1024, + })); + }); + + test('creates an asynchronous RSS parser for every request', async () => { + httpRequestService.send + .mockResolvedValueOnce(response('https://example.com/first.xml')) + .mockResolvedValueOnce(response('https://example.com/second.xml')); + + await exec('https://example.com/first.xml'); + await exec('https://example.com/second.xml'); + + expect(rssParserMocks.constructor).toHaveBeenCalledTimes(2); + expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(1, { xml2js: { async: true } }); + expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(2, { xml2js: { async: true } }); + }); + + test('rejects a non-HTTP final URL without exposing it', async () => { + httpRequestService.send.mockResolvedValue(response('file:///secret/feed.xml')); + + await expectApiError(exec('https://example.com/feed.xml'), 'FETCH_RSS_FAILED', 422); + }); + + test('shares an in-flight request for the same normalized URL', async () => { + const pending = deferred(); + httpRequestService.send.mockReturnValue(pending.promise); + + const first = exec('HTTPS://EXAMPLE.COM:443/feed.xml#first'); + const second = exec('https://example.com/feed.xml#second'); + expect(httpRequestService.send).toHaveBeenCalledTimes(1); + + pending.resolve(response('https://example.com/feed.xml')); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(httpRequestService.send).toHaveBeenCalledTimes(1); + }); + + test('limits concurrent requests for different URLs to 32', async () => { + const pending = deferred(); + httpRequestService.send.mockReturnValue(pending.promise); + + const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`)); + expect(httpRequestService.send).toHaveBeenCalledTimes(32); + await expectApiError(exec('https://example.com/overflow.xml'), 'FETCH_RSS_UNAVAILABLE', 503); + expect(httpRequestService.send).toHaveBeenCalledTimes(32); + + pending.resolve(response('https://example.com/feed.xml')); + await Promise.all(requests); + + httpRequestService.send.mockResolvedValue(response('https://example.com/after.xml')); + await expect(exec('https://example.com/after.xml')).resolves.toBeDefined(); + }); + + test('cleans up the in-flight request and concurrency slot after success', async () => { + httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml')); + + await exec('https://example.com/feed.xml'); + await exec('https://example.com/feed.xml'); + + expect(httpRequestService.send).toHaveBeenCalledTimes(2); + }); + + test('cleans up the in-flight request and concurrency slot after failure', async () => { + httpRequestService.send.mockRejectedValue(new Error('upstream failed')); + const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`)); + await Promise.allSettled(requests); + + httpRequestService.send.mockResolvedValue(response('https://example.com/0.xml')); + await expect(exec('https://example.com/0.xml')).resolves.toBeDefined(); + expect(httpRequestService.send).toHaveBeenCalledTimes(33); + }); + + test('has the expected rate limit metadata', () => { + expect(meta.limit).toEqual({ + duration: 60 * 1000, + max: 300, + }); + }); + + test('uses only the declared structured API errors', () => { + expect(new ApiError(meta.errors.invalidUrl)).toMatchObject({ code: 'INVALID_URL', httpStatusCode: 400 }); + expect(new ApiError(meta.errors.fetchRssFailed)).toMatchObject({ code: 'FETCH_RSS_FAILED', httpStatusCode: 422 }); + expect(new ApiError(meta.errors.fetchRssUnavailable)).toMatchObject({ code: 'FETCH_RSS_UNAVAILABLE', httpStatusCode: 503 }); + }); +}); diff --git a/packages/backend/test/unit/server/http-access-log.ts b/packages/backend/test/unit/server/http-access-log.ts new file mode 100644 index 00000000000..ddc235a076b --- /dev/null +++ b/packages/backend/test/unit/server/http-access-log.ts @@ -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; +}; + +type TestManager = { + readonly manager: LogManager; + readonly writeAccess: ReturnType; +}; + +/** 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[0] = {}): Promise { + 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(); + }); +}); diff --git a/packages/backend/test/unit/telemetry-registry.ts b/packages/backend/test/unit/telemetry-registry.ts new file mode 100644 index 00000000000..f3cdf5cc275 --- /dev/null +++ b/packages/backend/test/unit/telemetry-registry.ts @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Config } from '@/config.js'; + +const mocks = vi.hoisted(() => { + return { + sentryCreate: vi.fn(), + setLogTraceContextProvider: vi.fn(), + }; +}); + +vi.mock('@/logging/logging-runtime.js', () => ({ + setLogTraceContextProvider: mocks.setLogTraceContextProvider, +})); + +vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({ + SentryTelemetryAdapter: { + create: mocks.sentryCreate, + }, +})); + +function config(overrides: Partial): Config { + return { + version: '2026.1.0', + ...overrides, + } as Config; +} + +describe('telemetry-registry', () => { + beforeEach(() => { + vi.resetModules(); + mocks.sentryCreate.mockReset(); + mocks.setLogTraceContextProvider.mockReset(); + mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() }); + }); + + test('does not initialize an adapter when Sentry is not configured', async () => { + const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); + + await initTelemetry(config({})); + + expect(mocks.sentryCreate).not.toHaveBeenCalled(); + expect(mocks.setLogTraceContextProvider).not.toHaveBeenCalled(); + }); + + test('initializes Sentry and registers its trace context provider', async () => { + const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); + const sentryForBackend = { options: {}, enableNodeProfiling: false }; + const getActiveTraceContext = vi.fn(() => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + })); + mocks.sentryCreate.mockResolvedValue({ + shutdown: vi.fn(), + captureMessage: vi.fn(), + startSpan: vi.fn(), + getActiveTraceContext, + }); + + await initTelemetry(config({ sentryForBackend })); + + expect(mocks.sentryCreate).toHaveBeenCalledWith(sentryForBackend); + expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function)); + const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown; + expect(provider()).toEqual({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }); + expect(getActiveTraceContext).toHaveBeenCalledOnce(); + }); + + test('startSpan runs fn directly when no adapter is registered', async () => { + const { startSpan } = await import('@/core/telemetry/telemetry-registry.js'); + + const fn = vi.fn().mockReturnValue('result'); + expect(startSpan('test', fn)).toBe('result'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('startSpan delegates to the Sentry adapter', async () => { + const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js'); + const adapterStartSpan = vi.fn((_name: string, fn: () => string) => fn()); + mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan }); + + await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } })); + + const fn = vi.fn().mockReturnValue('result'); + expect(startSpan('test', fn)).toBe('result'); + expect(adapterStartSpan).toHaveBeenCalledWith('test', fn); + }); + + test('shutdownTelemetry waits for all registered adapters even when one rejects', async () => { + const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); + const firstShutdown = vi.fn().mockRejectedValue(new Error('first failed')); + const secondShutdown = vi.fn().mockResolvedValue(undefined); + mocks.sentryCreate + .mockResolvedValueOnce({ shutdown: firstShutdown, captureMessage: vi.fn(), startSpan: vi.fn() }) + .mockResolvedValueOnce({ shutdown: secondShutdown, captureMessage: vi.fn(), startSpan: vi.fn() }); + + const sentryForBackend = { options: {}, enableNodeProfiling: false }; + await initTelemetry(config({ sentryForBackend })); + await initTelemetry(config({ sentryForBackend })); + + await expect(shutdownTelemetry()).resolves.toBeUndefined(); + expect(firstShutdown).toHaveBeenCalledTimes(1); + expect(secondShutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/backend/vitest.config.fed.ts b/packages/backend/vitest.config.fed.ts index fcd58f8abbb..18fb8274da2 100644 --- a/packages/backend/vitest.config.fed.ts +++ b/packages/backend/vitest.config.fed.ts @@ -6,6 +6,8 @@ export default mergeConfig( defineConfig({ test: { include: ['test-federation/test/**/*.test.ts'], + // beforeAll / afterAll で連合の反映 (最大 WAIT_FOR_SLOW_FEDERATION) を待つ + hookTimeout: 60000, }, }), ); diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index ede45c1fcf5..306675fd910 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "exports": { @@ -11,16 +11,16 @@ }, "devDependencies": { "@types/estree": "1.0.9", - "@types/node": "26.0.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "rollup": "4.62.2" + "@types/node": "26.2.0", + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "rollup": "4.62.4" }, "dependencies": { "i18n": "workspace:*", - "magic-string": "0.30.21", - "oxc-walker": "1.0.0", - "rolldown": "1.1.3", - "vite": "8.1.0" + "magic-string": "1.1.0", + "oxc-walker": "1.1.1", + "rolldown": "1.2.3", + "vite": "8.2.1" } } diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts index c9855358c2a..df2ee59e293 100644 --- a/packages/frontend-embed/@types/global.d.ts +++ b/packages/frontend-embed/@types/global.d.ts @@ -14,8 +14,3 @@ declare const _PERF_PREFIX_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; - -// TagCanvas -interface Window { - TagCanvas: any; -} diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index d11a22baf0a..2094989e212 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -18,10 +18,10 @@ "mfm-js": "0.26.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "shiki": "4.3.0", + "shiki": "4.4.3", "tinycolor2": "1.6.0", "uuid": "14.0.1", - "vue": "3.5.39" + "vue": "3.5.41" }, "devDependencies": { "@misskey-dev/emoji-assets": "17.0.3", @@ -30,24 +30,25 @@ "@rollup/pluginutils": "5.4.0", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", - "@types/node": "26.0.1", + "@types/node": "26.2.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@vitest/coverage-v8": "4.1.9", - "@vitejs/plugin-vue": "6.0.7", - "@vue/runtime-core": "3.5.39", + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@vitest/coverage-v8": "4.1.10", + "@vitejs/plugin-vue": "6.0.8", + "@vue/runtime-core": "3.5.41", "eslint-plugin-import": "2.32.0", - "eslint-plugin-vue": "10.9.2", + "eslint-plugin-vue": "10.10.0", "intersection-observer": "0.12.2", - "lightningcss": "1.32.0", + "lightningcss": "1.33.0", "sass-embedded": "1.100.0", - "tsx": "4.22.4", - "vite": "8.1.0", - "vue-component-type-helpers": "3.3.5", + "tsx": "4.23.12", + "typescript": "6.0.3", + "vite": "8.2.1", + "vue-component-type-helpers": "3.3.9", "vue-eslint-parser": "10.4.1", - "vue-tsc": "3.3.5" + "vue-tsc": "3.3.9" } } diff --git a/packages/frontend-shared/@types/global.d.ts b/packages/frontend-shared/@types/global.d.ts index 5dc7b70a318..6980123f927 100644 --- a/packages/frontend-shared/@types/global.d.ts +++ b/packages/frontend-shared/@types/global.d.ts @@ -15,9 +15,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; -} diff --git a/packages/frontend-shared/js/interval.ts b/packages/frontend-shared/js/interval.ts new file mode 100644 index 00000000000..37abf8bb511 --- /dev/null +++ b/packages/frontend-shared/js/interval.ts @@ -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(); + }; +} diff --git a/packages/frontend-shared/js/url.ts b/packages/frontend-shared/js/url.ts index 1f3550d9517..b70c7cfee4f 100644 --- a/packages/frontend-shared/js/url.ts +++ b/packages/frontend-shared/js/url.ts @@ -27,6 +27,14 @@ export function extractDomain(url: string) { return match ? match[1] : null; } +export function tryParseUrl(url: string | URL, base?: string | URL): URL | null { + try { + return new URL(url, base); + } catch { + return null; + } +} + export function maybeMakeRelative(urlStr: string, baseStr: string): string { try { const baseObj = new URL(baseStr); diff --git a/packages/frontend-shared/js/use-interval.ts b/packages/frontend-shared/js/use-interval.ts index b50e78c3ccc..d9d00450e09 100644 --- a/packages/frontend-shared/js/use-interval.ts +++ b/packages/frontend-shared/js/use-interval.ts @@ -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(() => { diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index 8c14cc2c1d4..052546effea 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -4,15 +4,15 @@ "private": true, "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "26.0.1", + "@types/node": "26.2.0", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "eslint-plugin-vue": "10.9.2", + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "eslint-plugin-vue": "10.10.0", "vue-eslint-parser": "10.4.1" }, "files": [ @@ -23,8 +23,8 @@ "i18n": "workspace:*", "json5": "2.2.3", "misskey-js": "workspace:*", - "shiki": "4.3.0", + "shiki": "4.4.3", "tinycolor2": "1.6.0", - "vue": "3.5.39" + "vue": "3.5.41" } } diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 6005049dde3..01ddefa5448 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -458,7 +458,6 @@ function toStories(component: string): Promise { globSync('src/components/MkSignupServerRules.vue'), globSync('src/components/MkUserSetupDialog.vue'), globSync('src/components/MkUserSetupDialog.*.vue'), - globSync('src/components/MkImgPreviewDialog.vue'), globSync('src/components/MkInstanceCardMini.vue'), globSync('src/components/MkInviteCode.vue'), globSync('src/components/MkTagItem.vue'), diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts index c9855358c2a..df2ee59e293 100644 --- a/packages/frontend/@types/global.d.ts +++ b/packages/frontend/@types/global.d.ts @@ -14,8 +14,3 @@ declare const _PERF_PREFIX_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; - -// TagCanvas -interface Window { - TagCanvas: any; -} diff --git a/packages/frontend/assets/tagcanvas.min.js b/packages/frontend/assets/tagcanvas.min.js deleted file mode 100644 index bcee46e682f..00000000000 --- a/packages/frontend/assets/tagcanvas.min.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (C) 2010-2021 Graham Breach - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -/** - * TagCanvas 2.11 - * For more information, please contact - */ - (function(){"use strict";var r,C,p=Math.abs,o=Math.sin,l=Math.cos,g=Math.max,h=Math.min,af=Math.ceil,E=Math.sqrt,w=Math.pow,I={},D={},R={0:"0,",1:"17,",2:"34,",3:"51,",4:"68,",5:"85,",6:"102,",7:"119,",8:"136,",9:"153,",a:"170,",A:"170,",b:"187,",B:"187,",c:"204,",C:"204,",d:"221,",D:"221,",e:"238,",E:"238,",f:"255,",F:"255,"},f,d,b,T,z,F,M,c=document,v,e,P,j={};for(r=0;r<256;++r)C=r.toString(16),r<16&&(C='0'+C),D[C]=D[C.toUpperCase()]=r.toString()+',';function n(a){return typeof a!='undefined'}function B(a){return typeof a=='object'&&a!=null}function G(a,c,b){return isNaN(a)?b:h(b,g(c,a))}function x(){return!1}function q(){return(new Date).valueOf()}function ak(c,d){var b=[],e=c.length,a;for(a=0;a=1)?0:a<=-1?Math.PI:Math.acos(a)},z.unit=function(){var a=this.length();return new s(this.x/a,this.y/a,this.z/a)};function ay(b,a){a=a*Math.PI/180,b=b*Math.PI/180;var c=o(b)*l(a),d=-o(a),e=-l(b)*l(a);return new s(c,d,e)}function m(a){this[1]={1:a[0],2:a[1],3:a[2]},this[2]={1:a[3],2:a[4],3:a[5]},this[3]={1:a[6],2:a[7],3:a[8]}}T=m.prototype,m.Identity=function(){return new m([1,0,0,0,1,0,0,0,1])},m.Rotation=function(e,a){var c=o(e),d=l(e),b=1-d;return new m([d+w(a.x,2)*b,a.x*a.y*b-a.z*c,a.x*a.z*b+a.y*c,a.y*a.x*b+a.z*c,d+w(a.y,2)*b,a.y*a.z*b-a.x*c,a.z*a.x*b-a.y*c,a.z*a.y*b+a.x*c,d+w(a.z,2)*b])},T.mul=function(c){var d=[],a,b,e=c.xform?1:0;for(a=1;a<=3;++a)for(b=1;b<=3;++b)e?d.push(this[a][1]*c[1][b]+this[a][2]*c[2][b]+this[a][3]*c[3][b]):d.push(this[a][b]*c);return new m(d)},T.xform=function(b){var a={},c=b.x,d=b.y,e=b.z;return a.x=c*this[1][1]+d*this[2][1]+e*this[3][1],a.y=c*this[1][2]+d*this[2][2]+e*this[3][2],a.z=c*this[1][3]+d*this[2][3]+e*this[3][3],a};function aB(g,j,k,m,f){var a,b,c,d,e=[],h=2/g,i;i=Math.PI*(3-E(5)+(parseFloat(f)?parseFloat(f):0));for(a=0;a0)}function aC(a,c,f,d){var e=a.createLinearGradient(0,0,c,0),b;for(b in d)e.addColorStop(1-b,d[b]);a.fillStyle=e,a.fillRect(0,f,c,1)}function L(a,m,j){var l=1024,d=1,e=a.weightGradient,i,f,b,c;if(a.gCanvas)f=a.gCanvas.getContext('2d'),d=a.gCanvas.height;else{if(B(e[0])?d=e.length:e=[e],a.gCanvas=i=k(l,d),!i)return null;f=i.getContext('2d');for(b=0;b0?b=i*b/100:b=b*j,a=e.getContext('2d'),a.globalCompositeOperation='source-over',a.fillStyle='#fff',b>=i/2?(b=h(c,d)/2,a.beginPath(),a.moveTo(c/2,d/2),a.arc(c/2,d/2,b,0,2*Math.PI,!1),a.fill(),a.closePath()):(b=h(c/2,d/2,b),y(a,0,0,c,d,b,!0),a.fill()),a.globalCompositeOperation='source-in',a.drawImage(l,0,0,c,d),e)}function ao(q,m,i,b,h,a,c){var g=p(c[0]),f=p(c[1]),j=m+(g>a?g+a:a*2)*b,l=i+(f>a?f+a:a*2)*b,n=b*((a||0)+(c[0]<0?g:0)),o=b*((a||0)+(c[1]<0?f:0)),e,d;return e=k(j,l),!e?null:(d=e.getContext('2d'),h&&(d.shadowColor=h),a&&(d.shadowBlur=a*b),c&&(d.shadowOffsetX=c[0]*b,d.shadowOffsetY=c[1]*b),d.drawImage(q,n,o,m,i),{image:e,width:j/b,height:l/b})}function ae(m,o,l){var c=parseInt(m.toString().length*l),h=parseInt(l*2*m.length),j=k(c,h),g,i,e,f,b,d,n,a;if(!j)return null;g=j.getContext('2d'),g.fillStyle='#000',g.fillRect(0,0,c,h),Y(g,l+'px '+o,'#fff',m,0,0,0,0,[],'centre'),i=g.getImageData(0,0,c,h),e=i.width,f=i.height,a={min:{x:e,y:f},max:{x:-1,y:-1}};for(d=0;d0&&(ba.max.x&&(a.max.x=b),da.max.y&&(a.max.y=d));return e!=c&&(a.min.x*=c/e,a.max.x*=c/e),f!=h&&(a.min.y*=c/f,a.max.y*=c/f),j=null,a}function Q(a){return"'"+a.replace(/(\'|\")/g,'').replace(/\s*,\s*/g,"', '")+"'"}function t(b,d,a){a=a||c,a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent('on'+b,d)}function am(b,d,a){a=a||c,a.removeEventListener?a.removeEventListener(b,d):a.detachEvent('on'+b,d)}function A(g,e,j,a,b){var l=b.imageScale,h,c,k,m,f,d;if(!e.complete)return t('load',function(){A(g,e,j,a,b)},e);if(!g.complete)return t('load',function(){A(g,e,j,a,b)},g);if(j&&!j.complete)return t('load',function(){A(g,e,j,a,b)},j);e.width=e.width,e.height=e.height,l&&(g.width=e.width*l,g.height=e.height*l),a.iw=g.width,a.ih=g.height,b.txtOpt&&(c=g,h=b.zoomMax*b.txtScale,f=a.iw*h,d=a.ih*h,f0?(a.iw+=2*b.outlineIncrease,a.ih+=2*b.outlineIncrease,f=h*a.iw,d=h*a.ih,c=S(a.fimage,f,d),a.oimage=c,a.fimage=H(a.fimage,a.oimage.width,a.oimage.height)):(f=h*(a.iw+2*b.outlineIncrease),d=h*(a.ih+2*b.outlineIncrease),c=S(a.fimage,f,d),a.oimage=H(c,a.fimage.width,a.fimage.height))))),a.alt=j,a.Init()}function i(a,d){var b=c.defaultView,e=d.replace(/\-([a-z])/g,function(a){return a.charAt(1).toUpperCase()});return b&&b.getComputedStyle&&b.getComputedStyle(a,null).getPropertyValue(d)||a.currentStyle&&a.currentStyle[e]}function aj(c,d,e){var b=1,a;return d?b=1*(c.getAttribute(d)||e):(a=i(c,'font-size'))&&(b=a.indexOf('px')>-1&&a.replace('px','')*1||a.indexOf('pt')>-1&&a.replace('pt','')*1.25||a*3.3),b}function u(a){return a.target&&n(a.target.id)?a.target.id:a.srcElement.parentNode.id}function K(a,c){var b,d,e=parseInt(i(c,'width'))/c.width,f=parseInt(i(c,'height'))/c.height;return n(a.offsetX)?b={x:a.offsetX,y:a.offsetY}:(d=X(c.id),n(a.changedTouches)&&(a=a.changedTouches[0]),a.pageX&&(b={x:a.pageX-d.x,y:a.pageY-d.y})),b&&e&&f&&(b.x/=e,b.y/=f),b}function an(c){var d=c.target||c.fromElement.parentNode,b=a.tc[d.id];b&&(b.mx=b.my=-1,b.UnFreeze(),b.EndDrag())}function ad(e){var g,c=a,b,d,f=u(e);for(g in c.tc)b=c.tc[g],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);f&&c.tc[f]&&(b=c.tc[f],(d=K(e,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(e,d)),b.drawn=0)}function ap(b){var e=a,f=c.addEventListener?0:1,d=u(b);d&&b.button==f&&e.tc[d]&&e.tc[d].BeginDrag(b)}function aq(b){var f=a,g=c.addEventListener?0:1,e=u(b),d;e&&b.button==g&&f.tc[e]&&(d=f.tc[e],ad(b),!d.EndDrag()&&!d.touchState&&d.Clicked(b))}function ar(c){var e=u(c),b=e&&a.tc[e],d;b&&c.changedTouches&&(c.touches.length==1&&b.touchState==0?(b.touchState=1,b.BeginDrag(c),(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.drawn=0)):c.targetTouches.length==2&&b.pinchZoom?(b.touchState=3,b.EndDrag(),b.BeginPinch(c)):(b.EndDrag(),b.EndPinch(),b.touchState=0))}function ac(c){var d=u(c),b=d&&a.tc[d];if(b&&c.changedTouches){switch(b.touchState){case 1:b.Draw(),b.Clicked();break;break;case 2:b.EndDrag();break;case 3:b.EndPinch()}b.touchState=0}}function au(c){var f,e=a,b,d,g=u(c);for(f in e.tc)b=e.tc[f],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);if(b=g&&e.tc[g],b&&c.changedTouches&&b.touchState){switch(b.touchState){case 1:case 2:(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(c,d)&&(b.touchState=2));break;case 3:b.Pinch(c)}b.drawn=0}}function ab(b){var d=a,c=u(b);c&&d.tc[c]&&(b.cancelBubble=!0,b.returnValue=!1,b.preventDefault&&b.preventDefault(),d.tc[c].Wheel((b.wheelDelta||b.detail)>0))}function aw(d){var c,b=a;clearTimeout(b.scrollTimer);for(c in b.tc)b.tc[c].Pause();b.scrollTimer=setTimeout(function(){var b,c=a;for(b in c.tc)c.tc[b].Resume()},b.scrollPause)}function al(){Z(q())}function Z(b){var c=a.tc,d;a.NextFrame(a.interval),b=b||q();for(d in c)c[d].Draw(b)}function az(){requestAnimationFrame(Z)}function aA(a){setTimeout(al,a)}function X(f){var g=c.getElementById(f),b=g.getBoundingClientRect(),a=c.documentElement,d=c.body,e=window,h=e.pageXOffset||a.scrollLeft,i=e.pageYOffset||a.scrollTop,j=a.clientLeft||d.clientLeft,k=a.clientTop||d.clientTop;return{x:b.left+h-j,y:b.top+i-k}}function aI(a,b,d,e){var c=a.radius*a.z1/(a.z1+a.z2+b.z);return{x:b.x*c*d,y:b.y*c*e,z:b.z,w:(a.z1-b.z)/a.z2}}function V(a){this.e=a,this.br=0,this.line=[],this.text=[],this.original=a.innerText||a.textContent}F=V.prototype,F.Empty=function(){for(var a=0;ah?(d.push(this.line.join(' ')),this.line=[a[b]]):this.line.push(a[b]);d.push(this.line.join(' '))}return this.text=d};function _(a,b){this.ts=null,this.tc=a,this.tag=b,this.x=this.y=this.w=this.h=this.sc=1,this.z=0,this.pulse=1,this.pulsate=a.pulsateTo<1,this.colour=a.outlineColour,this.adash=~~a.outlineDash,this.agap=~~a.outlineDashSpace||this.adash,this.aspeed=a.outlineDashSpeed*1,this.colour=='tag'?this.colour=i(b.a,'color'):this.colour=='tagbg'&&(this.colour=i(b.a,'background-color')),this.Draw=this.pulsate?this.DrawPulsate:this.DrawSimple,this.radius=a.outlineRadius|0,this.SetMethod(a.outlineMethod,a.altImage)}f=_.prototype,f.SetMethod=function(a,d){var b={block:['PreDraw','DrawBlock'],colour:['PreDraw','DrawColour'],outline:['PostDraw','DrawOutline'],classic:['LastDraw','DrawOutline'],size:['PreDraw','DrawSize'],none:['LastDraw']},c=b[a]||b.outline;a=='none'?this.Draw=function(){return 1}:this.drawFunc=this[c[1]],this[c[0]]=this.Draw,d&&(this.RealPreDraw=this.PreDraw,this.PreDraw=this.DrawAlt)},f.Update=function(d,e,i,j,a,f,g,h){var b=this.tc.outlineOffset,c=2*b;this.x=a*d+g-b,this.y=a*e+h-b,this.w=a*i+c,this.h=a*j+c,this.sc=a,this.z=f},f.Ants=function(k){if(!this.adash)return;var b=this.adash,c=this.agap,a=this.aspeed,j=b+c,h=0,g=b,f=c,i=0,d=0,e;a&&(d=p(a)*(q()-this.ts)/50,a<0&&(d=864e4-d),a=~~d%j),a?(b>=a?(h=b-a,g=a):(f=j-a,i=c-f),e=[h,f,g,i]):e=[b,c],k.setLineDash(e)},f.DrawOutline=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.strokeStyle=f,this.Ants(a),y(a,d,e,b,c,g,!0)},f.DrawSize=function(i,n,m,l,k,j,a,h,g){var f=a.w,e=a.h,c,b,d;return this.pulsate?(a.image?d=(a.image.height+this.tc.outlineIncrease)/a.image.height:d=a.oscale,b=a.fimage||a.image,c=1+(d-1)*(1-this.pulse),a.h*=c,a.w*=c):b=a.oimage,a.alpha=1,a.Draw(i,h,g,b),a.h=e,a.w=f,1},f.DrawColour=function(d,h,i,e,f,g,a,b,c){return a.oimage?(this.pulse<1?(a.alpha=1-w(this.pulse,2),a.Draw(d,b,c,a.fimage),a.alpha=this.pulse):a.alpha=1,a.Draw(d,b,c,a.oimage),1):this[a.image?'DrawColourImage':'DrawColourText'](d,h,i,e,f,g,a,b,c)},f.DrawColourText=function(f,h,i,j,g,e,a,b,c){var d=a.colour;return a.colour=e,a.alpha=1,a.Draw(f,b,c),a.colour=d,1},f.DrawColourImage=function(a,q,p,o,n,m,i,r,l){var f=a.canvas,e=~~g(q,0),d=~~g(p,0),c=h(f.width-e,o)+.5|0,b=h(f.height-d,n)+.5|0,j;return v?(v.width=c,v.height=b):v=k(c,b),!v?this.SetMethod('outline'):(j=v.getContext('2d'),j.drawImage(f,e,d,c,b,0,0,c,b),a.clearRect(e,d,c,b),this.pulsate?i.alpha=1-w(this.pulse,2):i.alpha=1,i.Draw(a,r,l),a.setTransform(1,0,0,1,0,0),a.save(),a.beginPath(),a.rect(e,d,c,b),a.clip(),a.globalCompositeOperation='source-in',a.fillStyle=m,a.fillRect(e,d,c,b),a.restore(),a.globalAlpha=1,a.globalCompositeOperation='destination-over',a.drawImage(v,0,0,c,b,e,d,c,b),a.globalCompositeOperation='source-over',1)},f.DrawAlt=function(b,a,c,d,f,g){var e=this.RealPreDraw(b,a,c,d,f,g);return a.alt&&(a.DrawImage(b,c,d,a.alt),e=1),e},f.DrawBlock=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.fillStyle=f,y(a,d,e,b,c,g)},f.DrawSimple=function(a,b,c,d,e,f){var g=this.tc;return a.setTransform(1,0,0,1,0,0),a.strokeStyle=this.colour,a.lineWidth=g.outlineThickness,a.shadowBlur=a.shadowOffsetX=a.shadowOffsetY=0,a.globalAlpha=f?e:1,this.drawFunc(a,this.x,this.y,this.w,this.h,this.colour,b,c,d)},f.DrawPulsate=function(h,d,e,f){var g=q()-this.ts,c=this.tc,b=c.pulsateTo+(1-c.pulsateTo)*(.5+l(2*Math.PI*g/(1e3*c.pulsateTime))/2);return this.pulse=b=a.Smooth(1,b),this.DrawSimple(h,d,e,f,b,1)},f.Active=function(d,a,b){var c=a>=this.x&&b>=this.y&&a<=this.x+this.w&&b<=this.y+this.h;return c?this.ts=this.ts||q():this.ts=null,c},f.PreDraw=f.PostDraw=f.LastDraw=x;function J(a,h,c,b,e,f,g,d,i,j,k,l,m,n){this.tc=a,this.image=null,this.text=h,this.text_original=n,this.line_widths=[],this.title=c.title||null,this.a=c,this.position=new s(b[0],b[1],b[2]),this.x=this.y=this.z=0,this.w=e,this.h=f,this.colour=g||a.textColour,this.bgColour=d||a.bgColour,this.bgRadius=i|0,this.bgOutline=j||this.colour,this.bgOutlineThickness=k|0,this.textFont=l||a.textFont,this.padding=m|0,this.sc=this.alpha=1,this.weighted=!a.weight,this.outline=new _(a,this),this.audio=null}d=J.prototype,d.Init=function(b){var a=this.tc;this.textHeight=a.textHeight,this.HasText()?this.Measure(a.ctxt,a):(this.w=this.iw,this.h=this.ih),this.SetShadowColour=a.shadowAlpha?this.SetShadowColourAlpha:this.SetShadowColourFixed,this.SetDraw(a)},d.Draw=x,d.HasText=function(){return this.text&&this.text[0].length>0},d.EqualTo=function(a){var b=a.getElementsByTagName('img');return this.a.href!=a.href?0:b.length?this.image.src==b[0].src:(a.innerText||a.textContent)==this.text_original},d.SetImage=function(a){this.image=this.fimage=a},d.SetAudio=function(a){this.audio=a,this.audio.load()},d.SetDraw=function(a){this.Draw=this.fimage?a.ie>7?this.DrawImageIE:this.DrawImage:this.DrawText,a.noSelect&&(this.CheckActive=x)},d.MeasureText=function(d){var a,e=this.text.length,b=0,c;for(a=0;a0?c=H(c,this.oimage.width,this.oimage.height):this.oimage=H(this.oimage,c.width,c.height)),c&&(this.fimage=c,l=this.fimage.width/b,j=this.fimage.height/b),this.SetDraw(a),a.txtOpt=!!this.fimage),this.h=j,this.w=l},d.SetFont=function(a,b,c,d){this.textFont=a,this.colour=b,this.bgColour=c,this.bgOutline=d,this.Measure(this.tc.ctxt,this.tc)},d.SetWeight=function(c){var b=this.tc,e=b.weightMode.split(/[, ]/),d,a,f=c.length;if(!this.HasText())return;this.weighted=!0;for(a=0;a0&&a.weightSizeMax>a.weightSizeMin?this.textHeight=a.weightSize*(a.weightSizeMin+(a.weightSizeMax-a.weightSizeMin)*c):this.textHeight=g(1,b*a.weightSize))},d.SetShadowColourFixed=function(a,b,c){a.shadowColor=b},d.SetShadowColourAlpha=function(a,b,c){a.shadowColor=aE(b,c)},d.DrawText=function(a,h,i){var e=this.tc,g=this.x,f=this.y,c=this.sc,b,d;a.globalAlpha=this.alpha,a.fillStyle=this.colour,e.shadow&&this.SetShadowColour(a,e.shadow,this.alpha),a.font=this.font,g+=h/c,f+=i/c-this.h/2;for(b=0;b{this.stopped?this.audio.pause():this.playing=1}),1}};function a(f,o,k){var d,i,b=c.getElementById(f),l=['id','class','innerHTML'];if(!b)throw 0;if(n(window.G_vmlCanvasManager)&&(b=window.G_vmlCanvasManager.initElement(b),this.ie=parseFloat(navigator.appVersion.split('MSIE')[1])),b&&(!b.getContext||!b.getContext('2d').fillText)){i=c.createElement('DIV');for(d=0;d0?a.scrollPause=~~this.scrollPause:this.scrollPause=0,this.minTags>0&&this.repeatTags<1&&(d=this.GetTags().length)&&(this.repeatTags=af(this.minTags/d)-1),this.transform=m.Identity(),this.startTime=this.time=q(),this.mx=this.my=-1,this.centreImage&&av(this),this.Animate=this.dragControl?this.AnimateDrag:this.AnimatePosition,this.animTiming=typeof a[this.animTiming]=='function'?a[this.animTiming]:a.Smooth,this.shadowBlur||this.shadowOffset[0]||this.shadowOffset[1]?(this.ctxt.shadowColor=this.shadow,this.shadow=this.ctxt.shadowColor,this.shadowAlpha=aD()):delete this.shadow,this.activeAudio===!1?e='off':this.activeAudio&&this.LoadAudio(),this.Load(),o&&this.hideTags&&function(b){a.loaded?b.HideTags():t('load',function(){b.HideTags()},window)}(this),this.yaw=this.initial?this.initial[0]*this.maxSpeed:0,this.pitch=this.initial?this.initial[1]*this.maxSpeed:0,this.tooltip?(this.ctitle=b.title,b.title='',this.tooltip=='native'?this.Tooltip=this.TooltipNative:(this.Tooltip=this.TooltipDiv,this.ttdiv||(this.ttdiv=c.createElement('div'),this.ttdiv.className=this.tooltipClass,this.ttdiv.style.position='absolute',this.ttdiv.style.zIndex=b.style.zIndex+1,t('mouseover',function(a){a.target.style.display='none'},this.ttdiv),c.body.appendChild(this.ttdiv)))):this.Tooltip=this.TooltipNone,!this.noMouse&&!j[f]){j[f]=[['mousemove',ad],['mouseout',an],['mouseup',aq],['touchstart',ar],['touchend',ac],['touchcancel',ac],['touchmove',au]],this.dragControl&&(j[f].push(['mousedown',ap]),j[f].push(['selectstart',x])),this.wheelZoom&&(j[f].push(['mousewheel',ab]),j[f].push(['DOMMouseScroll',ab])),this.scrollPause&&j[f].push(['scroll',aw,window]);for(d=0;dthis.max_weight[a])&&(this.max_weight[a]=c),(!this.min_weight[a]||cthis.min_weight[a]&&(g=1);if(g)for(b=0;b=d&&this.my>=e)return!0},b.ToggleAudio=function(){var a=this.audioOff||e&&e.state==='suspended';a||this.currentAudio&&this.currentAudio.StopAudio(),this.audioOff=!a},b.Draw=function(s){if(this.paused)return;var l=this.canvas,i=l.width,j=l.height,q=0,p=(s-this.time)*a.interval/1e3,h=i/2+this.offsetX,g=j/2+this.offsetY,d=this.ctxt,b,f,c,o=-1,e=this.taglist,k=e.length,t=this.active&&this.active.tag,m='',u=this.frontSelect,r=this.centreFunc==x,n;if(this.time=s,this.frozen&&this.drawn)return this.Animate(i,j,p);n=this.AnimateFixed(),d.setTransform(1,0,0,1,0,0);for(c=0;c=0&&this.my>=0&&this.taglist[c].CheckActive(d,h,g),f&&f.sc>q&&(!u||f.z<=0)&&(b=f,o=c,b.tag=this.taglist[c],q=f.sc);this.active=b}this.txtOpt||this.shadow&&this.SetShadow(d),d.clearRect(0,0,i,j);for(c=0;c=this.fadeIn?(this.fadeIn=0,this.fixedAlpha=1):this.fixedAlpha=b/this.fadeIn),this.fixedAnim)&&(this.fixedAnim.transform||(this.fixedAnim.transform=this.transform),a=this.fixedAnim,b=q()-a.t0,c=a.angle,d,e=this.animTiming(a.t,b),this.transform=a.transform,b>=a.t?(this.fixedCallbackTag=a.tag,this.fixedCallback=a.cb,this.fixedAnim=this.yaw=this.pitch=0):c*=e,d=m.Rotation(c,a.axis),this.transform=this.transform.mul(d),this.fixedAnim!=0)},b.AnimatePosition=function(g,h,f){var a=this,d=a.mx,e=a.my,b,c;!a.frozen&&d>=0&&e>=0&&db&&(a.yaw=c>a.z0?a.yaw*a.decel:0),!a.ly&&d>b&&(a.pitch=d>a.z0?a.pitch*a.decel:0)},b.Zoom=function(a){this.z2=this.z1*(1/a),this.drawn=0},b.Clicked=function(b){if(this.CheckAudioIcon()){this.ToggleAudio();return}var a=this.active;try{a&&a.tag&&(this.clickToFront===!1||this.clickToFront===null?a.tag.Clicked(b):this.TagToFront(a.tag,this.clickToFront,function(){a.tag.Clicked(b)},!0))}catch(a){}},b.Wheel=function(a){var b=this.zoom+this.zoomStep*(a?1:-1);this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.BeginDrag=function(a){this.down=K(a,this.canvas),a.cancelBubble=!0,a.returnValue=!1,a.preventDefault&&a.preventDefault()},b.Drag=function(e,a){if(this.dragControl&&this.down){var d=this.dragThreshold*this.dragThreshold,b=a.x-this.down.x,c=a.y-this.down.y;(this.dragging||b*b+c*c>d)&&(this.dx=b,this.dy=c,this.dragging=1,this.down=a)}return this.dragging},b.EndDrag=function(){var a=this.dragging;return this.dragging=this.down=null,a};function ah(a){var b=a.targetTouches[0],c=a.targetTouches[1];return E(w(c.pageX-b.pageX,2)+w(c.pageY-b.pageY,2))}b.BeginPinch=function(a){this.pinched=[ah(a),this.zoom],a.preventDefault&&a.preventDefault()},b.Pinch=function(d){var b,c,a=this.pinched;if(!a)return;c=ah(d),b=a[1]*c/a[0],this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.EndPinch=function(a){this.pinched=null},b.Pause=function(){this.paused=!0},b.Resume=function(){this.paused=!1},b.SetSpeed=function(a){this.initial=a,this.yaw=a[0]*this.maxSpeed,this.pitch=a[1]*this.maxSpeed},b.FindTag=function(a){if(!n(a))return null;if(n(a.index)&&(a=a.index),!B(a))return this.taglist[a];var c,d,b;n(a.id)?(c='id',d=a.id):n(a.text)&&(c='innerText',d=a.text);for(b=0;b { import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js'; import { M as MkContainer } from './MkContainer-!~{03M}~.js'; import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js'; -import './photoswipe-!~{003}~.js'; const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1); const _sfc_main = /* @__PURE__ */ defineComponent({ @@ -179,7 +178,6 @@ export { index_photos as default }; import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js'; import { M as MkContainer } from './MkContainer-!~{03M}~.js'; import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js'; -import './photoswipe-!~{003}~.js'; const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1); const index_photos = /* @__PURE__ */ defineComponent({ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 03900af0422..d1d3e91b24b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -6,7 +6,7 @@ "watch": "vite", "build": "tsx build.ts", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", - "build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", + "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static", "chromatic": "chromatic", "test": "vitest --run --globals --config vitest.config.unit.ts", @@ -22,64 +22,64 @@ "@mcaptcha/core-glue": "0.1.0-alpha-5", "@misskey-dev/browser-image-resizer": "2024.1.0", "@misskey-dev/emoji-data": "17.0.3", - "@sentry/vue": "10.62.0", + "@misskey-dev/tagcanvas-es": "0.1.2", + "@sentry/vue": "10.70.0", "@simplewebauthn/browser": "13.3.0", "@syuilo/aiscript": "1.2.1", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", - "@vitejs/plugin-vue": "6.0.7", + "@vitejs/plugin-vue": "6.0.8", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16", "analytics": "0.8.19", - "broadcast-channel": "7.3.0", + "broadcast-channel": "7.4.0", "buraha": "0.0.1", "canvas-confetti": "1.9.4", "chart.js": "4.5.1", "chartjs-adapter-date-fns": "3.0.0", - "chartjs-chart-matrix": "3.0.4", + "chartjs-chart-matrix": "3.0.5", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", "compare-versions": "6.1.1", "cropperjs": "2.1.1", "date-fns": "4.4.0", "eventemitter3": "5.0.4", - "exifreader": "4.41.0", + "exifreader": "4.42.0", "frontend-shared": "workspace:*", "horizon-sky": "0.2.0", "i18n": "workspace:*", "icons-subsetter": "workspace:*", - "idb-keyval": "6.2.5", + "idb-keyval": "6.3.0", "insert-text-at-cursor": "0.3.0", "ios-haptics": "0.1.5", "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.20.0", - "mediabunny": "1.49.0", + "mediabunny": "1.53.0", "mfm-js": "0.26.0", "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", - "photoswipe": "5.4.4", "punycode.js": "2.3.1", "qr-code-styling": "1.9.2", "qr-scanner": "1.4.2", - "sanitize-html": "2.17.5", + "sanitize-html": "2.17.6", "seedrandom": "3.0.5", - "shiki": "4.3.0", + "shiki": "4.4.3", "textarea-caret": "3.1.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "v-code-diff": "1.13.1", - "vue": "3.5.39", + "vue": "3.5.41", "wanakana": "5.3.1" }, "devDependencies": { "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/summaly": "5.5.1", - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "@rollup/plugin-json": "6.1.0", "@rollup/pluginutils": "5.4.0", "@storybook/addon-essentials": "8.6.18", "@storybook/addon-interactions": "8.6.18", - "@storybook/addon-links": "10.4.6", + "@storybook/addon-links": "10.5.7", "@storybook/addon-mdx-gfm": "8.6.18", "@storybook/addon-storysource": "8.6.18", "@storybook/blocks": "8.6.18", @@ -90,8 +90,8 @@ "@storybook/test": "8.6.18", "@storybook/theming": "8.6.18", "@storybook/types": "8.6.18", - "@storybook/vue3": "10.4.6", - "@storybook/vue3-vite": "10.4.6", + "@storybook/vue3": "10.5.7", + "@storybook/vue3-vite": "10.5.7", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", @@ -99,49 +99,50 @@ "@types/insert-text-at-cursor": "0.3.2", "@types/matter-js": "0.20.2", "@types/micromatch": "4.0.10", - "@types/node": "26.0.1", + "@types/node": "26.2.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.1", "@types/seedrandom": "3.0.8", "@types/textarea-caret": "3.0.4", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vue/compiler-core": "3.5.39", + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vue/compiler-core": "3.5.41", "astring": "1.9.0", - "chromatic": "17.7.2", + "chromatic": "18.1.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-vue": "10.9.2", - "execa": "9.6.1", - "happy-dom": "20.10.6", + "eslint-plugin-vue": "10.10.0", + "execa": "10.0.1", + "happy-dom": "20.11.2", "intersection-observer": "0.12.2", - "lightningcss": "1.32.0", + "lightningcss": "1.33.0", "micromatch": "4.0.8", - "minimatch": "10.2.5", - "msw": "2.14.6", - "msw-storybook-addon": "2.0.7", + "minimatch": "10.2.6", + "msw": "2.15.0", + "msw-storybook-addon": "3.0.0", "nodemon": "3.1.14", - "oxc-walker": "1.0.0", - "playwright": "1.61.1", - "prettier": "3.9.1", - "react": "19.2.7", - "react-dom": "19.2.7", - "rolldown": "1.1.3", + "oxc-walker": "1.1.1", + "playwright": "1.62.1", + "prettier": "3.9.6", + "react": "19.2.8", + "react-dom": "19.2.8", + "rolldown": "1.2.3", "rollup-plugin-visualizer": "7.0.1", "sass-embedded": "1.100.0", - "storybook": "10.4.6", + "storybook": "10.5.7", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", - "tsx": "4.22.4", - "vite": "8.1.0", - "vite-plugin-glsl": "1.6.0", + "tsx": "4.23.12", + "typescript": "6.0.3", + "vite": "8.2.1", + "vite-plugin-glsl": "1.6.1", "vite-plugin-turbosnap": "1.0.3", - "vitest": "4.1.9", + "vitest": "4.1.10", "vitest-fetch-mock": "0.4.5", - "vue-component-type-helpers": "3.3.5", + "vue-component-type-helpers": "3.3.9", "vue-eslint-parser": "10.4.1", - "vue-tsc": "3.3.5" + "vue-tsc": "3.3.9" } } diff --git a/packages/frontend/src/components/MkBlurhash.vue b/packages/frontend/src/components/MkBlurhash.vue new file mode 100644 index 00000000000..8522ab260e0 --- /dev/null +++ b/packages/frontend/src/components/MkBlurhash.vue @@ -0,0 +1,186 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index e418e729ca9..2d96cb7572a 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -46,7 +46,7 @@ export type ChartSrc = diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 34ec522885b..d065aa89a16 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -14,18 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only :enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined" :leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined" > - + :blurhash="hash ?? null" + :height="props.height" + :width="props.width" + :onlyAvgColor="props.onlyAvgColor" + :show="hide" + /> - - diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 1622e57d640..9d53ffba883 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -54,7 +54,6 @@ type ModelValueType = diff --git a/packages/frontend/src/components/MkLightbox.item.controls.vue b/packages/frontend/src/components/MkLightbox.item.controls.vue new file mode 100644 index 00000000000..e2ac037a671 --- /dev/null +++ b/packages/frontend/src/components/MkLightbox.item.controls.vue @@ -0,0 +1,455 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkLightbox.item.fileinfo.vue b/packages/frontend/src/components/MkLightbox.item.fileinfo.vue new file mode 100644 index 00000000000..a57353e003b --- /dev/null +++ b/packages/frontend/src/components/MkLightbox.item.fileinfo.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkLightbox.item.vue b/packages/frontend/src/components/MkLightbox.item.vue new file mode 100644 index 00000000000..a192a638959 --- /dev/null +++ b/packages/frontend/src/components/MkLightbox.item.vue @@ -0,0 +1,1307 @@ + + + + + + + + + diff --git a/packages/frontend/src/components/MkLightbox.vue b/packages/frontend/src/components/MkLightbox.vue new file mode 100644 index 00000000000..94cd0bb6f33 --- /dev/null +++ b/packages/frontend/src/components/MkLightbox.vue @@ -0,0 +1,326 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index eb551f574de..a22783b52f8 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -6,10 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index c6445af1b88..3e722316d9f 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -4,15 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only -->