forked from mirrors/misskey
Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e0134b068 | |||
| 66c75896fd | |||
| 9b0b459731 | |||
| b55566ff96 | |||
| eb24927de4 | |||
| b68e5eba8f | |||
| eb83f40ef2 |
62 changed files with 2895 additions and 2663 deletions
|
|
@ -293,7 +293,7 @@ fulltextSearch:
|
|||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: 'aidx'
|
||||
id: "aidx"
|
||||
|
||||
# ┌────────────────┐
|
||||
#───┘ Error tracking └──────────────────────────────────────────
|
||||
|
|
@ -402,3 +402,33 @@ proxyBypassHosts:
|
|||
# # Disable query truncation. If set to true, the full text of the query will be output to the log.
|
||||
# # default: false
|
||||
# disableQueryTruncation: false
|
||||
|
||||
# ┌─────────────────────────────────────────┐
|
||||
#────┘ External login via OpenID Connect (SSO) └───────────────────────────────
|
||||
# Allow signing in to Misskey through an external OpenID Connect Identity
|
||||
# Provider (e.g. Authentik, Keycloak, Auth0). Misskey acts as the OIDC
|
||||
# Relying Party (client).
|
||||
#
|
||||
# Register Misskey as an application/client on your IdP and set its
|
||||
# redirect URI to: {url}/sso/oidc/callback
|
||||
# (where {url} is the `url:` value at the top of this file)
|
||||
#
|
||||
# NOTE: clientSecret is a credential. Do NOT commit a filled-in value.
|
||||
#sso:
|
||||
# oidc:
|
||||
# # Whether OIDC login is enabled. default: true (when this block exists)
|
||||
# enabled: true
|
||||
# # Display name shown on the login button. default: null ("Log in with SSO")
|
||||
# name: Authentik
|
||||
# # The issuer URL. Its {issuer}/.well-known/openid-configuration must resolve.
|
||||
# issuer: https://authentik.example.com/application/o/misskey/
|
||||
# clientId: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# clientSecret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# # Requested scopes. default: ['openid', 'profile', 'email']
|
||||
# scopes: ['openid', 'profile', 'email']
|
||||
# # Automatically create a Misskey account on first login if none is linked.
|
||||
# # When false, only pre-linked accounts may sign in. default: false
|
||||
# autoProvision: false
|
||||
# # The id_token claim used as the username on auto-provision.
|
||||
# # default: 'preferred_username'
|
||||
# usernameClaim: preferred_username
|
||||
|
|
|
|||
64
.forgejo/workflows/docker-forgejo.yml
Normal file
64
.forgejo/workflows/docker-forgejo.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
name: Publish Docker image (Forgejo)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
registry:
|
||||
description: 'Forgejo container registry host (e.g. codeberg.org)'
|
||||
required: true
|
||||
type: string
|
||||
default: 'codeberg.org'
|
||||
image:
|
||||
description: 'Image path within the registry (e.g. owner/misskey)'
|
||||
required: true
|
||||
type: string
|
||||
default: 'misskey/misskey'
|
||||
tag:
|
||||
description: 'Tag to publish (e.g. latest, develop, 2025.x.x)'
|
||||
required: true
|
||||
type: string
|
||||
default: 'latest'
|
||||
platforms:
|
||||
description: 'Target platforms to build'
|
||||
required: true
|
||||
type: choice
|
||||
default: 'linux/amd64,linux/arm64'
|
||||
options:
|
||||
- 'linux/amd64,linux/arm64'
|
||||
- 'linux/amd64'
|
||||
- 'linux/arm64'
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ inputs.registry }}/${{ inputs.image }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to Forgejo container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ inputs.registry }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: ${{ inputs.platforms }}
|
||||
provenance: false
|
||||
tags: ${{ env.REGISTRY_IMAGE }}:${{ inputs.tag }}
|
||||
labels: ${{ inputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ inputs.tag }}
|
||||
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
46
.github/scripts/backend-js-footprint-loader.mjs
vendored
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { appendFileSync, statSync } from 'node:fs';
|
||||
import { extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
|
||||
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||
|
||||
function recordLoadedFile(kind, url, format) {
|
||||
if (traceFile == null || !url.startsWith('file:')) return;
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = fileURLToPath(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = extname(filePath);
|
||||
if (!jsExtensions.has(extension)) return;
|
||||
|
||||
let size = null;
|
||||
try {
|
||||
size = statSync(filePath).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
appendFileSync(traceFile, `${JSON.stringify({
|
||||
kind,
|
||||
format,
|
||||
path: filePath,
|
||||
size,
|
||||
timestamp: Date.now(),
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
const result = await nextLoad(url, context);
|
||||
recordLoadedFile('esm', url, result.format ?? context.format ?? null);
|
||||
return result;
|
||||
}
|
||||
46
.github/scripts/backend-js-footprint-require.cjs
vendored
46
.github/scripts/backend-js-footprint-require.cjs
vendored
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { appendFileSync, statSync } = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
const { extname } = require('node:path');
|
||||
|
||||
const traceFile = process.env.MK_BACKEND_JS_FOOTPRINT_TRACE;
|
||||
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||
|
||||
function recordLoadedFile(kind, filePath, request) {
|
||||
if (traceFile == null || typeof filePath !== 'string') return;
|
||||
|
||||
const extension = extname(filePath);
|
||||
if (!jsExtensions.has(extension) && extension !== '.node') return;
|
||||
|
||||
let size = null;
|
||||
try {
|
||||
size = statSync(filePath).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
appendFileSync(traceFile, `${JSON.stringify({
|
||||
kind,
|
||||
format: extension === '.node' ? 'native' : 'commonjs',
|
||||
path: filePath,
|
||||
request,
|
||||
size,
|
||||
timestamp: Date.now(),
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
const originalLoad = Module._load;
|
||||
const originalResolveFilename = Module._resolveFilename;
|
||||
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
const resolved = originalResolveFilename.call(this, request, parent, isMain);
|
||||
const result = originalLoad.apply(this, arguments);
|
||||
recordLoadedFile('cjs', resolved, request);
|
||||
return result;
|
||||
};
|
||||
478
.github/scripts/backend-js-footprint.mjs
vendored
478
.github/scripts/backend-js-footprint.mjs
vendored
|
|
@ -1,478 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { fork, spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { cpus, tmpdir } from 'node:os';
|
||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const [repoDirArg, outputFileArg] = process.argv.slice(2);
|
||||
|
||||
const STARTUP_TIMEOUT = util.readIntegerEnv('MK_JS_FOOTPRINT_STARTUP_TIMEOUT_MS', 120000, 1);
|
||||
const SETTLE_TIME = util.readIntegerEnv('MK_JS_FOOTPRINT_SETTLE_TIME_MS', 10000, 0);
|
||||
const REQUEST_COUNT = util.readIntegerEnv('MK_JS_FOOTPRINT_REQUEST_COUNT', 10, 0);
|
||||
const MAX_TABLE_ITEMS = util.readIntegerEnv('MK_JS_FOOTPRINT_MAX_ITEMS', 20, 1);
|
||||
|
||||
const repoDir = resolve(repoDirArg);
|
||||
const outputFile = resolve(outputFileArg);
|
||||
const backendDir = join(repoDir, 'packages/backend');
|
||||
const backendBuiltDir = join(backendDir, 'built');
|
||||
const traceFile = join(tmpdir(), `misskey-backend-js-footprint-${process.pid}-${Date.now()}.jsonl`);
|
||||
const require = createRequire(join(repoDir, 'package.json'));
|
||||
const ts = require('typescript');
|
||||
const jsExtensions = new Set(['.js', '.mjs', '.cjs']);
|
||||
const fileMetricCache = new Map();
|
||||
const packageInfoCache = new Map();
|
||||
const nativePackageNames = new Set();
|
||||
|
||||
function isInside(parent, child) {
|
||||
const rel = relative(parent, child);
|
||||
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(sep).join('/');
|
||||
}
|
||||
|
||||
function bytesToKiB(value) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
async function resetState() {
|
||||
const backendRequire = createRequire(join(backendDir, 'package.json'));
|
||||
const pg = backendRequire('pg');
|
||||
const Redis = backendRequire('ioredis');
|
||||
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const req = http.request({
|
||||
host: 'localhost',
|
||||
port: 61812,
|
||||
path: '/api/meta',
|
||||
method: 'POST',
|
||||
}, res => {
|
||||
res.on('data', () => { });
|
||||
res.on('end', () => resolvePromise());
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServerReady(serverProcess) {
|
||||
let serverReady = false;
|
||||
serverProcess.on('message', message => {
|
||||
if (message === 'ok') serverReady = true;
|
||||
});
|
||||
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
throw new Error('Server startup timeout');
|
||||
}
|
||||
await setTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer(serverProcess) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
let exited = false;
|
||||
await new Promise(resolvePromise => {
|
||||
serverProcess.on('exit', () => {
|
||||
exited = true;
|
||||
resolvePromise(undefined);
|
||||
});
|
||||
|
||||
setTimeout(10000).then(() => {
|
||||
if (!exited) serverProcess.kill('SIGKILL');
|
||||
resolvePromise(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPackageNameFromPath(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
const marker = '/node_modules/';
|
||||
const index = normalized.lastIndexOf(marker);
|
||||
if (index === -1) return null;
|
||||
|
||||
const rest = normalized.slice(index + marker.length).split('/');
|
||||
if (rest[0] === '.pnpm') {
|
||||
const nestedNodeModulesIndex = rest.indexOf('node_modules');
|
||||
if (nestedNodeModulesIndex === -1) return null;
|
||||
const packageParts = rest.slice(nestedNodeModulesIndex + 1);
|
||||
if (packageParts.length === 0) return null;
|
||||
return packageParts[0].startsWith('@') ? packageParts.slice(0, 2).join('/') : packageParts[0];
|
||||
}
|
||||
|
||||
return rest[0]?.startsWith('@') ? rest.slice(0, 2).join('/') : rest[0] ?? null;
|
||||
}
|
||||
|
||||
function findPackageDir(filePath, packageName) {
|
||||
const normalizedPackageName = packageName.split('/').join(sep);
|
||||
let current = dirname(filePath);
|
||||
|
||||
while (current !== dirname(current)) {
|
||||
if (current.endsWith(`${sep}${normalizedPackageName}`) && fsSync.existsSync(join(current, 'package.json'))) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPackageInfo(filePath) {
|
||||
const externalPackageName = getPackageNameFromPath(filePath);
|
||||
if (externalPackageName != null) {
|
||||
const packageDir = findPackageDir(filePath, externalPackageName);
|
||||
const cacheKey = packageDir ?? externalPackageName;
|
||||
if (packageInfoCache.has(cacheKey)) return packageInfoCache.get(cacheKey);
|
||||
|
||||
let version = null;
|
||||
if (packageDir != null) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fsSync.readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
||||
version = typeof packageJson.version === 'string' ? packageJson.version : null;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const info = {
|
||||
category: 'external',
|
||||
name: externalPackageName,
|
||||
version,
|
||||
dir: packageDir,
|
||||
};
|
||||
packageInfoCache.set(cacheKey, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
if (isInside(backendBuiltDir, filePath)) {
|
||||
return {
|
||||
category: 'internal',
|
||||
name: 'backend',
|
||||
version: null,
|
||||
dir: backendDir,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
category: 'internal',
|
||||
name: 'workspace',
|
||||
version: null,
|
||||
dir: repoDir,
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeSource(filePath, source) {
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||
const metrics = {
|
||||
astNodeCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
stringLiteralBytes: 0,
|
||||
};
|
||||
|
||||
function visit(node) {
|
||||
metrics.astNodeCount += 1;
|
||||
|
||||
if (
|
||||
ts.isFunctionDeclaration(node) ||
|
||||
ts.isFunctionExpression(node) ||
|
||||
ts.isArrowFunction(node) ||
|
||||
ts.isMethodDeclaration(node) ||
|
||||
ts.isConstructorDeclaration(node) ||
|
||||
ts.isGetAccessorDeclaration(node) ||
|
||||
ts.isSetAccessorDeclaration(node)
|
||||
) {
|
||||
metrics.functionCount += 1;
|
||||
} else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
||||
metrics.classCount += 1;
|
||||
} else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
metrics.stringLiteralBytes += Buffer.byteLength(node.text);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function readFileMetrics(filePath) {
|
||||
if (fileMetricCache.has(filePath)) return fileMetricCache.get(filePath);
|
||||
|
||||
const source = fsSync.readFileSync(filePath);
|
||||
const sourceText = source.toString('utf8');
|
||||
const astMetrics = analyzeSource(filePath, sourceText);
|
||||
const packageInfo = readPackageInfo(filePath);
|
||||
const metric = {
|
||||
path: filePath,
|
||||
displayPath: normalizePath(relative(repoDir, filePath)),
|
||||
sourceBytes: source.byteLength,
|
||||
gzipBytes: gzipSync(source).byteLength,
|
||||
...astMetrics,
|
||||
package: packageInfo,
|
||||
};
|
||||
|
||||
fileMetricCache.set(filePath, metric);
|
||||
return metric;
|
||||
}
|
||||
|
||||
async function readTraceRecords() {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(traceFile, 'utf8');
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
|
||||
const records = [];
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
try {
|
||||
records.push(JSON.parse(line));
|
||||
} catch { }
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function emptyTotals() {
|
||||
return {
|
||||
loadedJsModules: 0,
|
||||
loadedJsSourceBytes: 0,
|
||||
loadedJsGzipBytes: 0,
|
||||
astNodeCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
stringLiteralBytes: 0,
|
||||
externalPackageCount: 0,
|
||||
nativeAddonPackageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function addFileMetrics(target, metric) {
|
||||
target.loadedJsModules += 1;
|
||||
target.loadedJsSourceBytes += metric.sourceBytes;
|
||||
target.loadedJsGzipBytes += metric.gzipBytes;
|
||||
target.astNodeCount += metric.astNodeCount;
|
||||
target.functionCount += metric.functionCount;
|
||||
target.classCount += metric.classCount;
|
||||
target.stringLiteralBytes += metric.stringLiteralBytes;
|
||||
}
|
||||
|
||||
function summarizeRecords(records, phase) {
|
||||
const jsPaths = new Set();
|
||||
const nativePaths = new Set();
|
||||
|
||||
for (const record of records) {
|
||||
if (typeof record.path !== 'string') continue;
|
||||
|
||||
const extension = extname(record.path);
|
||||
if (jsExtensions.has(extension)) {
|
||||
jsPaths.add(resolve(record.path));
|
||||
} else if (extension === '.node') {
|
||||
nativePaths.add(resolve(record.path));
|
||||
}
|
||||
}
|
||||
|
||||
for (const nativePath of nativePaths) {
|
||||
const packageInfo = readPackageInfo(nativePath);
|
||||
if (packageInfo.category === 'external') nativePackageNames.add(packageInfo.name);
|
||||
}
|
||||
|
||||
const totals = emptyTotals();
|
||||
const packages = new Map();
|
||||
const modules = [];
|
||||
|
||||
for (const filePath of [...jsPaths].toSorted()) {
|
||||
let metric;
|
||||
try {
|
||||
metric = readFileMetrics(filePath);
|
||||
} catch (err) {
|
||||
process.stderr.write(`Failed to analyze ${filePath}: ${err.message}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
addFileMetrics(totals, metric);
|
||||
|
||||
const packageKey = metric.package.name;
|
||||
if (!packages.has(packageKey)) {
|
||||
packages.set(packageKey, {
|
||||
name: metric.package.name,
|
||||
version: metric.package.version,
|
||||
category: metric.package.category,
|
||||
sourceBytes: 0,
|
||||
gzipBytes: 0,
|
||||
modules: 0,
|
||||
astNodeCount: 0,
|
||||
functionCount: 0,
|
||||
classCount: 0,
|
||||
stringLiteralBytes: 0,
|
||||
nativeAddon: false,
|
||||
});
|
||||
}
|
||||
|
||||
const packageSummary = packages.get(packageKey);
|
||||
packageSummary.sourceBytes += metric.sourceBytes;
|
||||
packageSummary.gzipBytes += metric.gzipBytes;
|
||||
packageSummary.modules += 1;
|
||||
packageSummary.astNodeCount += metric.astNodeCount;
|
||||
packageSummary.functionCount += metric.functionCount;
|
||||
packageSummary.classCount += metric.classCount;
|
||||
packageSummary.stringLiteralBytes += metric.stringLiteralBytes;
|
||||
|
||||
modules.push({
|
||||
path: metric.displayPath,
|
||||
package: metric.package.name,
|
||||
category: metric.package.category,
|
||||
sourceBytes: metric.sourceBytes,
|
||||
gzipBytes: metric.gzipBytes,
|
||||
astNodeCount: metric.astNodeCount,
|
||||
functionCount: metric.functionCount,
|
||||
classCount: metric.classCount,
|
||||
stringLiteralBytes: metric.stringLiteralBytes,
|
||||
});
|
||||
}
|
||||
|
||||
for (const packageName of nativePackageNames) {
|
||||
const packageSummary = packages.get(packageName);
|
||||
if (packageSummary != null) packageSummary.nativeAddon = true;
|
||||
}
|
||||
|
||||
const externalPackages = [...packages.values()].filter(packageSummary => packageSummary.category === 'external');
|
||||
totals.externalPackageCount = externalPackages.length;
|
||||
totals.nativeAddonPackageCount = externalPackages.filter(packageSummary => packageSummary.nativeAddon).length;
|
||||
|
||||
return {
|
||||
totals: {
|
||||
...totals,
|
||||
loadedJsSourceKiB: bytesToKiB(totals.loadedJsSourceBytes),
|
||||
loadedJsGzipKiB: bytesToKiB(totals.loadedJsGzipBytes),
|
||||
stringLiteralKiB: bytesToKiB(totals.stringLiteralBytes),
|
||||
},
|
||||
packages: [...packages.values()].toSorted((a, b) => b.sourceBytes - a.sourceBytes),
|
||||
modules: modules.toSorted((a, b) => b.sourceBytes - a.sourceBytes).slice(0, MAX_TABLE_ITEMS),
|
||||
};
|
||||
}
|
||||
|
||||
async function measureFootprint() {
|
||||
await fs.writeFile(traceFile, '');
|
||||
|
||||
process.stderr.write('Resetting database and Redis\n');
|
||||
await resetState();
|
||||
|
||||
process.stderr.write('Running migrations\n');
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
const serverProcess = fork(join(backendBuiltDir, 'entry.js'), [], {
|
||||
cwd: backendDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_ONLY_SERVER: '1',
|
||||
MK_NO_DAEMONS: '1',
|
||||
MK_BACKEND_JS_FOOTPRINT_TRACE: traceFile,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [
|
||||
'--require',
|
||||
join(__dirname, 'backend-js-footprint-require.cjs'),
|
||||
'--experimental-loader',
|
||||
pathToFileURL(join(__dirname, 'backend-js-footprint-loader.mjs')).href,
|
||||
],
|
||||
});
|
||||
|
||||
serverProcess.stdout?.on('data', data => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', data => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.on('error', err => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServerReady(serverProcess);
|
||||
await setTimeout(SETTLE_TIME);
|
||||
|
||||
//const startup = summarizeRecords(await readTraceRecords(), 'startup');
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
);
|
||||
await setTimeout(1000);
|
||||
|
||||
const afterRequest = summarizeRecords(await readTraceRecords(), 'afterRequest');
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
measurement: {
|
||||
strategy: 'runtime-loader-trace',
|
||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||
settleTimeMs: SETTLE_TIME,
|
||||
requestCount: REQUEST_COUNT,
|
||||
cpus: cpus().length,
|
||||
},
|
||||
phases: {
|
||||
//startup,
|
||||
afterRequest,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await stopServer(serverProcess);
|
||||
await fs.rm(traceFile, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await measureFootprint();
|
||||
await fs.writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`);
|
||||
205
.github/scripts/backend-memory-report.mjs
vendored
Normal file
205
.github/scripts/backend-memory-report.mjs
vendored
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
if (baseFile == null || headFile == null || outputFile == null) {
|
||||
console.error('Usage: node .github/scripts/backend-memory-report.mjs <base-memory.json> <head-memory.json> <report.md>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const phases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'Private_Dirty',
|
||||
'VmRSS',
|
||||
'External',
|
||||
];
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
function formatMemory(valueKiB) {
|
||||
return `${formatNumber(valueKiB / 1024)} MB`;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
function formatMathText(text) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
function formatColoredDiff(text, diff) {
|
||||
const color = diff > 0 ? 'orange' : 'green';
|
||||
return `$\\color{${color}}{\\text{${formatMathText(text).replaceAll('\\%', '\\\\%')}}}$`;
|
||||
}
|
||||
|
||||
function formatDiff(baseKiB, headKiB) {
|
||||
const diff = headKiB - baseKiB;
|
||||
if (diff === 0) return formatMemory(0);
|
||||
|
||||
const sign = diff > 0 ? '+' : '-';
|
||||
return formatColoredDiff(`${sign}${formatMemory(Math.abs(diff))}`, diff);
|
||||
}
|
||||
|
||||
function formatDiffPercent(baseKiB, headKiB) {
|
||||
const diff = headKiB - baseKiB;
|
||||
if (diff === 0) return '0%';
|
||||
if (baseKiB <= 0) return '-';
|
||||
|
||||
const sign = diff > 0 ? '+' : '-';
|
||||
return formatColoredDiff(`${sign}${formatPercent(Math.abs((diff * 100) / baseKiB))}`, diff);
|
||||
}
|
||||
|
||||
function getMemoryValue(report, phase, metric) {
|
||||
const value = report?.[phase]?.[metric];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
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);
|
||||
}
|
||||
|
||||
function getSampleValues(report, phase, metric) {
|
||||
if (!Array.isArray(report?.samples)) return [];
|
||||
|
||||
return report.samples
|
||||
.map(sample => getMemoryValue(sample, phase, metric))
|
||||
.filter(value => Number.isFinite(value));
|
||||
}
|
||||
|
||||
function getSampleSpread(report, phase, metric) {
|
||||
const values = getSampleValues(report, phase, metric);
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = median(values);
|
||||
return median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function renderTable(base, head, phase) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const metric of metrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
|
||||
lines.push(`| ${metric} | ${formatMemory(baseValue)} <br> ± ${formatMemory(baseSpread)} | ${formatMemory(headValue)} <br> ± ${formatMemory(headSpread)} | ${formatDiff(baseValue, headValue)} | ${formatDiffPercent(baseValue, headValue)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getDiffPercent(base, head, phase, metric) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null || baseValue <= 0) return null;
|
||||
|
||||
return ((headValue - baseValue) * 100) / baseValue;
|
||||
}
|
||||
|
||||
function getWarningMetric(base, head) {
|
||||
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS']) {
|
||||
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBeyondSampleNoise(base, head, phase, metric) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null) return false;
|
||||
|
||||
const diff = headValue - baseValue;
|
||||
if (diff <= 0) return false;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return diff > combinedSpread * 3;
|
||||
}
|
||||
|
||||
function workflowFooter() {
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
if (repository == null || runId == null) {
|
||||
return 'See workflow logs for details.';
|
||||
}
|
||||
|
||||
return `[See workflow logs for details](https://github.com/${repository}/actions/runs/${runId})`;
|
||||
}
|
||||
|
||||
function measurementSummary(base, head) {
|
||||
const baseCount = base?.sampleCount;
|
||||
const headCount = head?.sampleCount;
|
||||
const strategy = base?.comparison?.strategy;
|
||||
if (baseCount == null || headCount == null) return null;
|
||||
|
||||
if (strategy === 'interleaved-pairs') {
|
||||
const rounds = base?.comparison?.rounds ?? baseCount;
|
||||
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
|
||||
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
|
||||
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8'));
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8'));
|
||||
const lines = [
|
||||
'## Backend Memory Usage Report',
|
||||
'',
|
||||
];
|
||||
|
||||
const summary = measurementSummary(base, head);
|
||||
if (summary != null) {
|
||||
lines.push(summary);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const phase of phases) {
|
||||
lines.push(`### ${phase.title}`);
|
||||
lines.push(renderTable(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const warningMetric = getWarningMetric(base, head);
|
||||
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
|
||||
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
||||
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(workflowFooter());
|
||||
|
||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||
673
.github/scripts/backend-memory-report.mts
vendored
673
.github/scripts/backend-memory-report.mts
vendored
|
|
@ -1,673 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import * as util from './utility.mts';
|
||||
import type { MemoryReport } from './measure-backend-memory-comparison.mts';
|
||||
|
||||
const [baseFile, headFile, outputFile, baseJsFootprintFile, headJsFootprintFile] = process.argv.slice(2);
|
||||
|
||||
type RuntimeLoadedJsFootprintReport = {
|
||||
phases: Record<'afterRequest', {
|
||||
totals: {
|
||||
loadedJsModules: number;
|
||||
loadedJsSourceBytes: number;
|
||||
loadedJsGzipBytes: number;
|
||||
astNodeCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
stringLiteralBytes: number;
|
||||
externalPackageCount: number;
|
||||
nativeAddonPackageCount: number;
|
||||
};
|
||||
modules: {
|
||||
path: string;
|
||||
package: string;
|
||||
category: string;
|
||||
sourceBytes: number;
|
||||
gzipBytes: number;
|
||||
astNodeCount: number;
|
||||
functionCount: number;
|
||||
classCount: number;
|
||||
stringLiteralBytes: number;
|
||||
}[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'Private_Dirty',
|
||||
'VmRSS',
|
||||
'External',
|
||||
] as const;
|
||||
|
||||
const heapSnapshotCategoriesColors = {
|
||||
'Total': 'gray',
|
||||
'Code': 'orange',
|
||||
'Strings': 'red',
|
||||
'JS arrays': 'cyan',
|
||||
'Typed arrays': 'green',
|
||||
'System objects': 'yellow',
|
||||
'Other JS objects': 'violet',
|
||||
'Other non-JS objects': 'pink',
|
||||
} as const;
|
||||
|
||||
const heapSnapshotCategoriesColorsHex = {
|
||||
'Total': '#888888',
|
||||
'Code': '#f28e2c',
|
||||
'Strings': '#e15759',
|
||||
'JS arrays': '#76b7b2',
|
||||
'Typed arrays': '#59a14f',
|
||||
'System objects': '#edc949',
|
||||
'Other JS objects': '#af7aa1',
|
||||
'Other non-JS objects': '#ff9da7',
|
||||
} as const;
|
||||
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
return `${util.formatNumber(valueKiB / 1024)} MB`;
|
||||
}
|
||||
|
||||
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return report.summary[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
return sample.phases[phase].memoryUsage[metric];
|
||||
}
|
||||
|
||||
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const values = report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function getSamplesByRound(report: MemoryReport) {
|
||||
const samplesByRound = new Map<number, MemoryReport['samples'][number]>();
|
||||
if (!Array.isArray(report.samples)) return samplesByRound;
|
||||
|
||||
for (const sample of report.samples) {
|
||||
if (sample.round <= 0) continue;
|
||||
samplesByRound.set(sample.round, sample);
|
||||
}
|
||||
|
||||
return samplesByRound;
|
||||
}
|
||||
|
||||
function formatDeltaMemory(diffKiB: number) {
|
||||
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
|
||||
}
|
||||
|
||||
function pairedDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const baseSamplesByRound = getSamplesByRound(base);
|
||||
const headSamplesByRound = getSamplesByRound(head);
|
||||
const values = [];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getMemoryValueFromSample(baseSample, phase, metric);
|
||||
const headValue = getMemoryValueFromSample(headSample, phase, metric);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
values.push(headValue - baseValue);
|
||||
}
|
||||
|
||||
return {
|
||||
median: util.median(values),
|
||||
mad: util.mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const metric of metrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
const summary = pairedDeltaSummary(base, head, phase, metric);
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
|
||||
|
||||
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null || baseValue <= 0) return null;
|
||||
|
||||
return ((headValue - baseValue) * 100) / baseValue;
|
||||
}
|
||||
|
||||
/*
|
||||
function measurementSummary(base, head) {
|
||||
const baseCount = base?.sampleCount;
|
||||
const headCount = head?.sampleCount;
|
||||
const strategy = base?.comparison?.strategy;
|
||||
if (baseCount == null || headCount == null) return null;
|
||||
|
||||
if (strategy === 'interleaved-pairs') {
|
||||
const rounds = base?.comparison?.rounds ?? baseCount;
|
||||
const warmupRounds = base?.comparison?.warmupRounds ?? 0;
|
||||
return `_Measured as ${rounds} interleaved base/head pairs after ${warmupRounds} warmup pair(s). Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
|
||||
return `_Sample count: base ${baseCount}, head ${headCount}. Values are medians; ± is median absolute deviation._`;
|
||||
}
|
||||
*/
|
||||
|
||||
function formatPlainDelta(baseValue: number, headValue: number, formatter = util.formatNumber) {
|
||||
const delta = headValue - baseValue;
|
||||
if (delta === 0) return formatter(0);
|
||||
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
return `${sign}${formatter(Math.abs(delta))}`;
|
||||
}
|
||||
|
||||
function getHeapSnapshotCategoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const value = report.summary[phase]?.heapSnapshot?.categories?.[category];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function getHeapSnapshotCategoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const value = sample.phases[phase]?.heapSnapshot?.categories?.[category];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const heapSnapshotSankeyChildMinRatio = 0.3;
|
||||
const heapSnapshotSankeyParentMinPercent = 10;
|
||||
|
||||
function escapeCsvValue(value: string) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function formatSankeyPercentValue(value: number) {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded === 0 && value > 0) return '0.01';
|
||||
if (Number.isInteger(rounded)) return String(rounded);
|
||||
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatHeapSnapshotSankeyChildLabel(label: string) {
|
||||
return String(label).replace(/^[^:]+:\s*/, '');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSankey(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], title: string) {
|
||||
const total = getHeapSnapshotCategoryValue(report, phase, 'Total');
|
||||
if (total == null || total <= 0) return null;
|
||||
|
||||
function getHeapSnapshotBreakdownEntries(category: typeof util.heapSnapshotCategories[number]) {
|
||||
const breakdown = report.summary[phase].heapSnapshot?.breakdowns?.[category];
|
||||
if (breakdown == null || typeof breakdown !== 'object') return [];
|
||||
|
||||
return Object.entries(breakdown)
|
||||
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
const categories = util.heapSnapshotCategories
|
||||
.filter(category => category !== 'Total')
|
||||
.map(category => {
|
||||
const value = getHeapSnapshotCategoryValue(report, phase, category);
|
||||
if (value == null || value <= 0) return null;
|
||||
const breakdownEntries = getHeapSnapshotBreakdownEntries(category);
|
||||
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
|
||||
const percent = (value * 100) / total;
|
||||
const childEntries = [];
|
||||
let otherPercent = 0;
|
||||
|
||||
if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) {
|
||||
for (const [childName, childValue] of breakdownEntries) {
|
||||
const childRatio = childValue / breakdownTotal;
|
||||
const childPercent = percent * childRatio;
|
||||
if (childRatio >= heapSnapshotSankeyChildMinRatio) {
|
||||
childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]);
|
||||
} else {
|
||||
otherPercent += childPercent;
|
||||
}
|
||||
}
|
||||
|
||||
if (childEntries.length > 0 && otherPercent > 0) {
|
||||
childEntries.push(['Other', otherPercent]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
percent,
|
||||
childEntries,
|
||||
};
|
||||
})
|
||||
.filter(value => value != null);
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const nodeColors = {
|
||||
[title]: heapSnapshotCategoriesColorsHex.Total,
|
||||
} as Record<string, string>;
|
||||
for (const { category, childEntries } of categories) {
|
||||
const categoryColor = heapSnapshotCategoriesColorsHex[category] ?? heapSnapshotCategoriesColorsHex.Total;
|
||||
nodeColors[category] = categoryColor;
|
||||
|
||||
for (const [childName] of childEntries) {
|
||||
nodeColors[childName] = categoryColor;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title} heap snapshot composition</summary>`,
|
||||
'',
|
||||
'```mermaid',
|
||||
`%%{init: ${JSON.stringify({
|
||||
sankey: {
|
||||
showValues: false,
|
||||
linkColor: 'target',
|
||||
labelStyle: 'outlined',
|
||||
nodeAlignment: 'center',
|
||||
nodePadding: 10,
|
||||
nodeColors: {
|
||||
...nodeColors,
|
||||
'Other': '#888888',
|
||||
},
|
||||
},
|
||||
})}}%%`,
|
||||
'sankey-beta',
|
||||
];
|
||||
|
||||
for (const { category, percent, childEntries } of categories) {
|
||||
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(category)},${formatSankeyPercentValue(percent)}`);
|
||||
|
||||
for (const [childName, childPercent] of childEntries) {
|
||||
lines.push(`${escapeCsvValue(category)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('</details>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pairedHeapSnapshotDeltaSummary(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const baseSamplesByRound = getSamplesByRound(base);
|
||||
const headSamplesByRound = getSamplesByRound(head);
|
||||
const values = [] as number[];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getHeapSnapshotCategoryValueFromSample(baseSample, phase, category);
|
||||
const headValue = getHeapSnapshotCategoryValueFromSample(headSample, phase, category);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
values.push(headValue - baseValue);
|
||||
}
|
||||
|
||||
return {
|
||||
median: util.median(values),
|
||||
mad: util.mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeapSnapshotTable(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
const baseTotal = getHeapSnapshotCategoryValue(base, phase, 'Total');
|
||||
const headTotal = getHeapSnapshotCategoryValue(head, phase, 'Total');
|
||||
|
||||
function formatHeapSnapshotCategoryLabel(category: typeof heapSnapshotCategories[number], baseValue: number, headValue: number, baseTotal: number, headTotal: number) {
|
||||
if (category === 'Total' || baseTotal == null || headTotal == null || baseTotal <= 0 || headTotal <= 0) return `**${category}**`;
|
||||
|
||||
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
|
||||
const headPercent = util.formatPercent((headValue * 100) / headTotal);
|
||||
return `**${category}**<br>${basePercent} → ${headPercent}`;
|
||||
}
|
||||
|
||||
function getHeapSnapshotSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], category: typeof util.heapSnapshotCategories[number]) {
|
||||
const values = report.samples
|
||||
.map(sample => getHeapSnapshotCategoryValueFromSample(sample, phase, category))
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
const baseValue = getHeapSnapshotCategoryValue(base, phase, category);
|
||||
const headValue = getHeapSnapshotCategoryValue(head, phase, category);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
const baseSpread = getHeapSnapshotSampleSpread(base, phase, category);
|
||||
const headSpread = getHeapSnapshotSampleSpread(head, phase, category);
|
||||
const summary = pairedHeapSnapshotDeltaSummary(base, head, phase, category);
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = summary == null ? '-' : `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent)}`;
|
||||
const categoryLabel = formatHeapSnapshotCategoryLabel(category, baseValue, headValue, baseTotal, headTotal);
|
||||
|
||||
lines.push(`| $\\color{${heapSnapshotCategoriesColors[category]}}{\\rule{8pt}{8pt}}$ ${categoryLabel} | ${util.formatBytes(baseValue)} <br> ± ${baseSpread == null ? '-' : util.formatBytes(baseSpread)} | ${util.formatBytes(headValue)} <br> ± ${headSpread == null ? '-' : util.formatBytes(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : util.formatBytes(summary.mad)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.min)} | ${summary == null ? '-' : util.formatDeltaBytes(summary.max)} |`);
|
||||
if (category === 'Total') {
|
||||
lines.push('| | | | | | | |');
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 2) return null;
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const table = renderHeapSnapshotTable(base, head, 'afterGc');
|
||||
if (table == null) return null;
|
||||
|
||||
const lines = [
|
||||
'### V8 Heap Snapshot Statistics',
|
||||
'',
|
||||
table,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const graph of [
|
||||
renderHeapSnapshotSankey(base, 'afterGc', 'Base'),
|
||||
renderHeapSnapshotSankey(head, 'afterGc', 'Head'),
|
||||
]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getJsFootprintValue(report: RuntimeLoadedJsFootprintReport, phase: 'afterRequest', key: keyof RuntimeLoadedJsFootprintReport['phases'][typeof phase]['totals']) {
|
||||
const value = report.phases[phase].totals[key];
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const metricRows = [
|
||||
['Loaded JS modules', 'loadedJsModules', util.formatNumber],
|
||||
['Loaded JS source', 'loadedJsSourceBytes', util.formatBytes],
|
||||
//['Loaded JS gzip estimate', 'loadedJsGzipBytes', util.formatBytes],
|
||||
//['AST nodes', 'astNodeCount', util.formatNumber],
|
||||
//['Functions', 'functionCount', util.formatNumber],
|
||||
//['Classes', 'classCount', util.formatNumber],
|
||||
//['String literals', 'stringLiteralBytes', util.formatBytes],
|
||||
['External packages loaded', 'externalPackageCount', util.formatNumber],
|
||||
['Native addon packages', 'nativeAddonPackageCount', util.formatNumber],
|
||||
] as const;
|
||||
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const [title, key, formatter] of metricRows) {
|
||||
const baseValue = getJsFootprintValue(base, 'afterRequest', key);
|
||||
const headValue = getJsFootprintValue(head, 'afterRequest', key);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatPlainDelta(baseValue, headValue, formatter)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/*
|
||||
function renderJsFootprintPhaseTable(base, head) {
|
||||
const lines = [
|
||||
'| Phase | Base modules | Head modules | Δ modules | Base source | Head source | Δ source |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const [phase, title] of [['startup', 'Startup'], ['afterRequest', 'After warmup requests']]) {
|
||||
const baseModules = getJsFootprintValue(base, phase, 'loadedJsModules');
|
||||
const headModules = getJsFootprintValue(head, phase, 'loadedJsModules');
|
||||
const baseSource = getJsFootprintValue(base, phase, 'loadedJsSourceBytes');
|
||||
const headSource = getJsFootprintValue(head, phase, 'loadedJsSourceBytes');
|
||||
if (baseModules == null || headModules == null || baseSource == null || headSource == null) continue;
|
||||
|
||||
lines.push(`| ${title} | ${util.formatNumber(baseModules)} | ${util.formatNumber(headModules)} | ${formatPlainDelta(baseModules, headModules)} | ${util.formatBytes(baseSource)} | ${util.formatBytes(headSource)} | ${formatPlainDelta(baseSource, headSource, util.formatBytes)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
*/
|
||||
|
||||
function packageMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const packageSummary of report.phases.afterRequest.packages) {
|
||||
if (packageSummary?.category !== 'external' || typeof packageSummary.name !== 'string') continue;
|
||||
map.set(packageSummary.name, packageSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function packageDisplayName(packageSummary: { name: string; version?: string | null }) {
|
||||
if (packageSummary.version == null) return packageSummary.name;
|
||||
return `${packageSummary.name} ${packageSummary.version}`;
|
||||
}
|
||||
|
||||
function renderNewExternalPackages(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const newPackages = [...headPackages.values()]
|
||||
.filter(packageSummary => !basePackages.has(packageSummary.name))
|
||||
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||
.slice(0, 10);
|
||||
|
||||
if (newPackages.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Newly Loaded External Packages',
|
||||
'',
|
||||
'| Package | Loaded JS | Modules | Notes |',
|
||||
'| --- | ---: | ---: | --- |',
|
||||
];
|
||||
|
||||
for (const packageSummary of newPackages) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatNumber(packageSummary.modules)} | ${packageSummary.nativeAddon ? 'native addon' : ''} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const basePackages = packageMap(base);
|
||||
const headPackages = packageMap(head);
|
||||
const increases = [...headPackages.values()]
|
||||
.map(headPackage => {
|
||||
const basePackage = basePackages.get(headPackage.name);
|
||||
const baseSourceBytes = basePackage?.sourceBytes ?? 0;
|
||||
const baseModules = basePackage?.modules ?? 0;
|
||||
return {
|
||||
...headPackage,
|
||||
baseSourceBytes,
|
||||
baseModules,
|
||||
sourceDiff: headPackage.sourceBytes - baseSourceBytes,
|
||||
moduleDiff: headPackage.modules - baseModules,
|
||||
};
|
||||
})
|
||||
.filter(packageSummary => packageSummary.sourceDiff > 0)
|
||||
.toSorted((a, b) => b.sourceDiff - a.sourceDiff)
|
||||
.slice(0, 10);
|
||||
|
||||
if (increases.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Largest Package Increases',
|
||||
'',
|
||||
'| Package | Base | Head | Δ | Modules Δ |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const packageSummary of increases) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${formatPlainDelta(packageSummary.baseSourceBytes, packageSummary.sourceBytes, util.formatBytes)} | ${formatPlainDelta(packageSummary.baseModules, packageSummary.modules)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderNewLoadedModules(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
function moduleMap(report: RuntimeLoadedJsFootprintReport) {
|
||||
const map = new Map();
|
||||
for (const moduleSummary of report.phases.afterRequest.modules) {
|
||||
if (typeof moduleSummary.path !== 'string') continue;
|
||||
map.set(moduleSummary.path, moduleSummary);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const baseModules = moduleMap(base);
|
||||
const headModules = moduleMap(head);
|
||||
const newModules = [...headModules.values()]
|
||||
.filter(moduleSummary => !baseModules.has(moduleSummary.path))
|
||||
.toSorted((a, b) => b.sourceBytes - a.sourceBytes)
|
||||
.slice(0, 10);
|
||||
|
||||
if (newModules.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
'#### Largest Newly Loaded Modules',
|
||||
'',
|
||||
'| Module | Package | Loaded JS |',
|
||||
'| --- | --- | ---: |',
|
||||
];
|
||||
|
||||
for (const moduleSummary of newModules) {
|
||||
lines.push(`| \`${moduleSummary.path}\` | ${moduleSummary.package} | ${util.formatBytes(moduleSummary.sourceBytes)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderJsFootprintSection(base: RuntimeLoadedJsFootprintReport, head: RuntimeLoadedJsFootprintReport) {
|
||||
const lines = [
|
||||
'### Runtime Loaded JS Footprint',
|
||||
'',
|
||||
'<details><summary>Click to show</summary>',
|
||||
'',
|
||||
renderJsFootprintMetricTable(base, head),
|
||||
'',
|
||||
//'#### Load Phase Breakdown',
|
||||
//'',
|
||||
//renderJsFootprintPhaseTable(base, head),
|
||||
//'',
|
||||
];
|
||||
|
||||
for (const block of [
|
||||
renderNewExternalPackages(base, head),
|
||||
renderLargestPackageIncreases(base, head),
|
||||
renderNewLoadedModules(base, head),
|
||||
]) {
|
||||
if (block == null) continue;
|
||||
lines.push(block);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('</details>');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||
const baseJsFootprint = JSON.parse(await readFile(baseJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const headJsFootprint = JSON.parse(await readFile(headJsFootprintFile, 'utf8')) as RuntimeLoadedJsFootprintReport;
|
||||
const lines = [
|
||||
'## ⚙️ Backend Memory Usage Report',
|
||||
'',
|
||||
];
|
||||
|
||||
//const summary = measurementSummary(base, head);
|
||||
//if (summary != null) {
|
||||
// lines.push(summary);
|
||||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### ${phase.title}`);
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||
if (heapSnapshotSection != null) {
|
||||
lines.push(heapSnapshotSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const jsFootprintSection = renderJsFootprintSection(baseJsFootprint, headJsFootprint);
|
||||
if (jsFootprintSection != null) {
|
||||
lines.push(jsFootprintSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
function getWarningMetric(base: MemoryReport, head: MemoryReport) {
|
||||
for (const metric of ['Pss', 'Private_Dirty', 'VmRSS'] as const) {
|
||||
if (getMemoryValue(base, 'afterGc', metric) != null && getMemoryValue(head, 'afterGc', metric) != null) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null) return false;
|
||||
|
||||
const diff = headValue - baseValue;
|
||||
if (diff <= 0) return false;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return diff > combinedSpread * 3;
|
||||
}
|
||||
|
||||
const warningMetric = getWarningMetric(base, head);
|
||||
const warningDiffPercent = warningMetric == null ? null : getDiffPercent(base, head, 'afterGc', warningMetric);
|
||||
if (warningMetric != null && warningDiffPercent != null && warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
||||
lines.push(`⚠️ **Warning**: Memory usage (${warningMetric}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`[See workflow logs for details](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`);
|
||||
|
||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||
|
|
@ -5,54 +5,129 @@
|
|||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const marker = '<!-- misskey-frontend-js-size -->';
|
||||
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
|
||||
const byteFormatter = new Intl.NumberFormat('en-US');
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
const locale = process.env.FRONTEND_JS_SIZE_LOCALE ?? 'ja-JP';
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
//function sharePercent(value, total) {
|
||||
// if (total === 0) return '0%';
|
||||
// return Math.round((value / total) * 100) + '%';
|
||||
//}
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCell(value: string) {
|
||||
async function fileSize(filePath) {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
async function* walk(dir) {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walk(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function escapeLatex(text) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
function formatColoredDiff(text, diff) {
|
||||
if (diff === 0) return text;
|
||||
const color = diff > 0 ? 'orange' : 'green';
|
||||
const sign = diff > 0 ? '+' : '-';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
|
||||
}
|
||||
|
||||
function formatNumberDiff(before, after) {
|
||||
if (before == null || after == null) return '-';
|
||||
const diff = after - before;
|
||||
return formatColoredDiff(formatNumber(Math.abs(diff)), diff);
|
||||
}
|
||||
|
||||
function formatBytesDiff(before, after) {
|
||||
if (before == null || after == null) return '-';
|
||||
const diff = after - before;
|
||||
if (diff === 0) return '0 B';
|
||||
return formatColoredDiff(formatBytes(Math.abs(diff)), diff);
|
||||
}
|
||||
|
||||
function formatDiffPercent(before, after) {
|
||||
if (before == null || before === 0 || after == null || after === 0) return '-';
|
||||
const diff = after - before;
|
||||
if (diff === 0) return `0%`;
|
||||
const percent = Math.round(diff / before * 100);
|
||||
return formatColoredDiff(`${percent}%`, diff);
|
||||
}
|
||||
|
||||
function sharePercent(value, total) {
|
||||
if (total === 0) return '0%';
|
||||
return Math.round((value / total) * 100) + '%';
|
||||
}
|
||||
|
||||
function escapeCell(value) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
//function tableCell(value) {
|
||||
// return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
//}
|
||||
function tableCell(value) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
}
|
||||
|
||||
//function code(value) {
|
||||
// const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
// const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||
// const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||
// const fence = '`'.repeat(fenceLength);
|
||||
// const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
||||
//
|
||||
// return `${fence}${padding}${sanitized}${padding}${fence}`;
|
||||
//}
|
||||
function code(value) {
|
||||
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||
const fence = '`'.repeat(fenceLength);
|
||||
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
||||
|
||||
//function tableCode(value) {
|
||||
// return tableCell(code(value));
|
||||
//}
|
||||
return `${fence}${padding}${sanitized}${padding}${fence}`;
|
||||
}
|
||||
|
||||
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||
function tableCode(value) {
|
||||
return tableCell(code(value));
|
||||
}
|
||||
|
||||
type FileEntry = {
|
||||
key: string;
|
||||
displayName: string;
|
||||
file: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
function entryDisplayName(entry: FileEntry) {
|
||||
function entryDisplayName(entry) {
|
||||
if (!entry) return '';
|
||||
return entry.displayName || entry.file;
|
||||
}
|
||||
|
||||
function findEntryKey(manifest: Manifest) {
|
||||
function findEntryKey(manifest) {
|
||||
const entries = Object.entries(manifest);
|
||||
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
||||
|
|
@ -60,16 +135,16 @@ function findEntryKey(manifest: Manifest) {
|
|||
?? null;
|
||||
}
|
||||
|
||||
function stableChunkKey(manifestKey: string, chunk: Manifest[string]) {
|
||||
function stableChunkKey(manifestKey, chunk) {
|
||||
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
|
||||
}
|
||||
|
||||
function collectStartupKeys(manifest: Manifest) {
|
||||
function collectStartupKeys(manifest) {
|
||||
const entryKey = findEntryKey(manifest);
|
||||
const keys = new Set<string>();
|
||||
const keys = new Set();
|
||||
if (entryKey == null) return keys;
|
||||
|
||||
function visit(key: string) {
|
||||
function visit(key) {
|
||||
if (keys.has(key)) return;
|
||||
const chunk = manifest[key];
|
||||
if (!chunk || !chunk.file?.endsWith('.js')) return;
|
||||
|
|
@ -83,11 +158,11 @@ function collectStartupKeys(manifest: Manifest) {
|
|||
return keys;
|
||||
}
|
||||
|
||||
async function resolveBuiltFile(outDir: string, file: string) {
|
||||
async function resolveBuiltFile(outDir, file) {
|
||||
if (file.startsWith('scripts/')) {
|
||||
const localizedFile = file.slice('scripts/'.length);
|
||||
const localizedPath = path.join(outDir, locale, localizedFile);
|
||||
if (await util.fileExists(localizedPath)) {
|
||||
if (await exists(localizedPath)) {
|
||||
return {
|
||||
absolutePath: localizedPath,
|
||||
relativePath: `${locale}/${localizedFile}`,
|
||||
|
|
@ -102,17 +177,17 @@ async function resolveBuiltFile(outDir: string, file: string) {
|
|||
};
|
||||
}
|
||||
|
||||
async function collectReport(repoDir: string) {
|
||||
async function collectReport(repoDir) {
|
||||
const outDir = path.join(repoDir, 'built/_frontend_vite_');
|
||||
const manifestPath = path.join(outDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
|
||||
const byKey = new Map<string, FileEntry>();
|
||||
const byFile = new Set<string>();
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||
const byKey = new Map();
|
||||
const byFile = new Set();
|
||||
|
||||
for (const [key, chunk] of Object.entries(manifest)) {
|
||||
if (!chunk.file?.endsWith('.js')) continue;
|
||||
const builtFile = await resolveBuiltFile(outDir, chunk.file);
|
||||
const size = await util.fileSize(builtFile.absolutePath);
|
||||
const size = await fileSize(builtFile.absolutePath);
|
||||
const stableKey = stableChunkKey(key, chunk);
|
||||
const displayName = chunk.src ?? chunk.name ?? key;
|
||||
byKey.set(stableKey, {
|
||||
|
|
@ -125,12 +200,12 @@ async function collectReport(repoDir: string) {
|
|||
}
|
||||
|
||||
const localeDir = path.join(outDir, locale);
|
||||
if (await util.fileExists(localeDir)) {
|
||||
for await (const fullPath of util.traverseDirectory(localeDir)) {
|
||||
if (await exists(localeDir)) {
|
||||
for await (const fullPath of walk(localeDir)) {
|
||||
if (!fullPath.endsWith('.js')) continue;
|
||||
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
|
||||
const relativePath = normalizePath(path.relative(outDir, fullPath));
|
||||
if (byFile.has(relativePath)) continue;
|
||||
const size = await util.fileSize(fullPath);
|
||||
const size = await fileSize(fullPath);
|
||||
byKey.set(relativePath, {
|
||||
key: relativePath,
|
||||
displayName: relativePath,
|
||||
|
|
@ -147,28 +222,7 @@ async function collectReport(repoDir: string) {
|
|||
};
|
||||
}
|
||||
|
||||
type VisualizerReport = {
|
||||
nodeParts?: Record<string, {
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
nodeMetas?: Record<string, {
|
||||
id: string;
|
||||
isEntry?: boolean;
|
||||
isExternal?: boolean;
|
||||
importedBy?: string[];
|
||||
imported?: { id: string; dynamic?: boolean }[];
|
||||
moduleParts?: Record<string, string>;
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
function collectVisualizerReport(data: VisualizerReport) {
|
||||
function collectVisualizerReport(data) {
|
||||
const nodeParts = data.nodeParts ?? {};
|
||||
const nodeMetas = Object.values(data.nodeMetas ?? {});
|
||||
const moduleRows = [];
|
||||
|
|
@ -250,7 +304,7 @@ function collectVisualizerReport(data: VisualizerReport) {
|
|||
};
|
||||
}
|
||||
|
||||
function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
function renderVisualizerSummaryTable(before, after) {
|
||||
const summary = [
|
||||
'bundles',
|
||||
'modules',
|
||||
|
|
@ -258,13 +312,13 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
|
|||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
] as const;
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
] as const;
|
||||
];
|
||||
|
||||
return [
|
||||
`<table>`,
|
||||
|
|
@ -288,31 +342,31 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
|
|||
`<tbody>`,
|
||||
`<tr>`,
|
||||
`<th><b>Before</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>After</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
];
|
||||
}
|
||||
|
||||
function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
function getChunkComparisonRows(keys, before, after) {
|
||||
return keys.map((key) => {
|
||||
const beforeEntry = before.chunks[key];
|
||||
const afterEntry = after.chunks[key];
|
||||
|
|
@ -330,7 +384,7 @@ function getChunkComparisonRows(keys: string[], before: Awaited<ReturnType<typeo
|
|||
});
|
||||
}
|
||||
|
||||
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
|
||||
function summarizeChunkChanges(rows) {
|
||||
return {
|
||||
updated: rows.filter((row) => row.changeType === 'updated').length,
|
||||
added: rows.filter((row) => row.changeType === 'added').length,
|
||||
|
|
@ -338,18 +392,18 @@ function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>)
|
|||
};
|
||||
}
|
||||
|
||||
function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
|
||||
function formatChunkChangeSummary(label, summary) {
|
||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||
}
|
||||
|
||||
function compareChunkComparisonRows(a: ReturnType<typeof getChunkComparisonRows>[number], b: ReturnType<typeof getChunkComparisonRows>[number]) {
|
||||
function compareChunkComparisonRows(a, b) {
|
||||
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
||||
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
||||
|| b.sortSize - a.sortSize
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, total?: { beforeSize: number; afterSize: number }) {
|
||||
function chunkMarkdownTable(rows, total) {
|
||||
if (rows.length === 0) return '_No data_';
|
||||
|
||||
const lines = [
|
||||
|
|
@ -357,22 +411,22 @@ function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, tot
|
|||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
if (total != null) {
|
||||
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatBytesDiff(total.beforeSize, total.afterSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
|
||||
lines.push('| | | | | |');
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
|
||||
} else if (row.changeType === 'removed') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
function renderFrontendChunkReport(before, after) {
|
||||
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
|
||||
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
|
||||
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
|
||||
|
|
@ -410,14 +464,14 @@ function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectRepo
|
|||
|
||||
return [
|
||||
'<details open>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
`<summary>${formatChunkChangeSummary('Diffs', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
`<summary>${formatChunkChangeSummary('Startup', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal),
|
||||
'',
|
||||
|
|
@ -435,16 +489,15 @@ function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectRepo
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
function renderFrontendBundleReport(before, after) {
|
||||
const lines = [
|
||||
...renderVisualizerSummaryTable(before, after),
|
||||
'',
|
||||
//'<details>',
|
||||
//'<summary>Top 10</summary>',
|
||||
//'',
|
||||
'<details>',
|
||||
'<summary>Top 10</summary>',
|
||||
'',
|
||||
];
|
||||
|
||||
/*
|
||||
for (const row of after.hotModules.slice(0, 10)) {
|
||||
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
|
||||
}
|
||||
|
|
@ -471,98 +524,27 @@ function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerR
|
|||
'',
|
||||
'</details>',
|
||||
);
|
||||
*/
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const visualizerTreemapLimit = 50;
|
||||
|
||||
function mermaidTreemapLabel(value: string) {
|
||||
const label = String(value)
|
||||
.replaceAll('\\', '/')
|
||||
.replaceAll('"', "'")
|
||||
.replaceAll('`', "'")
|
||||
.replaceAll('\r', ' ')
|
||||
.replaceAll('\n', ' ')
|
||||
.trim();
|
||||
return label === '' ? '(unknown)' : label;
|
||||
}
|
||||
|
||||
function mermaidTreemapModuleLabel(id: string) {
|
||||
const normalizedId = String(id).replaceAll('\\', '/');
|
||||
const filePath = normalizedId.split(/[?#]/, 1)[0];
|
||||
const fileName = path.posix.basename(filePath);
|
||||
return mermaidTreemapLabel(fileName || normalizedId);
|
||||
}
|
||||
|
||||
function renderVisualizerTreemap(label: string, report: ReturnType<typeof collectVisualizerReport>) {
|
||||
const rows = report.hotModules
|
||||
.filter((row) => row.renderedLength > 0)
|
||||
.slice(0, visualizerTreemapLimit);
|
||||
const topRendered = rows.reduce((sum, row) => sum + row.renderedLength, 0);
|
||||
const otherRendered = Math.max(0, report.metrics.renderedLength - topRendered);
|
||||
const lines = [
|
||||
'```mermaid',
|
||||
`%%{init: ${JSON.stringify({
|
||||
treemap: {
|
||||
diagramPadding: 0,
|
||||
padding: 0,
|
||||
nodeHeight: 70,
|
||||
},
|
||||
})}}%%`,
|
||||
'treemap-beta',
|
||||
`"${mermaidTreemapLabel(label)}"`,
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
lines.push(` "${mermaidTreemapModuleLabel(row.id)}": ${Math.round(row.renderedLength)}`);
|
||||
}
|
||||
if (otherRendered > 0) {
|
||||
lines.push(` "Other": ${Math.round(otherRendered)}`);
|
||||
}
|
||||
|
||||
lines.push('```');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderVisualizerTreemapDetails(label: string, report: ReturnType<typeof collectVisualizerReport>, open = false) {
|
||||
return [
|
||||
`<details${open ? ' open' : ''}>`,
|
||||
`<summary>${label} rendered size treemap (top ${visualizerTreemapLimit} + Other)</summary>`,
|
||||
'',
|
||||
renderVisualizerTreemap(label, report),
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
|
||||
const before = await collectReport(beforeDir);
|
||||
const after = await collectReport(afterDir);
|
||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
||||
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
||||
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
||||
const visualizerArtifactLink = `[Open detailed HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8'));
|
||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8'));
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'',
|
||||
`## 📦 Frontend Bundle Report`,
|
||||
`## Frontend Chunk Report`,
|
||||
'',
|
||||
renderFrontendChunkReport(before, after),
|
||||
'',
|
||||
'## Bundle Stats',
|
||||
'## Frontend Bundle Report',
|
||||
'',
|
||||
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
|
||||
'',
|
||||
renderVisualizerTreemapDetails('Before', beforeVisualizerReport),
|
||||
'',
|
||||
renderVisualizerTreemapDetails('After', afterVisualizerReport),
|
||||
'',
|
||||
visualizerArtifactLink,
|
||||
renderFrontendBundleReport(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(outFile, body);
|
||||
224
.github/scripts/measure-backend-memory-comparison.mjs
vendored
Normal file
224
.github/scripts/measure-backend-memory-comparison.mjs
vendored
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
|
||||
console.error('Usage: node .github/scripts/measure-backend-memory-comparison.mjs <base-dir> <head-dir> <base-output.json> <head-output.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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 commandName(command) {
|
||||
if (process.platform !== 'win32') return command;
|
||||
if (command === 'pnpm') return 'pnpm.cmd';
|
||||
return command;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(commandName(command), args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', data => {
|
||||
stdout += data;
|
||||
if (options.logStdout) process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data;
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout);
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resetState(repoDir) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
const pg = require('pg');
|
||||
const Redis = require('ioredis');
|
||||
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
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);
|
||||
}
|
||||
|
||||
function summarizeSamples(samples) {
|
||||
const summary = {};
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {};
|
||||
|
||||
const metricKeys = new Set();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample[phase] ?? {})) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples
|
||||
.map(sample => sample[phase]?.[key])
|
||||
.filter(value => Number.isFinite(value));
|
||||
|
||||
if (values.length > 0) summary[phase][key] = median(values);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureRepo(label, repoDir, round, orderIndex) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
const stdout = await run('node', ['packages/backend/scripts/measure-memory.mjs'], {
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
...process.env,
|
||||
MK_MEMORY_SAMPLE_COUNT: '1',
|
||||
},
|
||||
});
|
||||
|
||||
const report = JSON.parse(stdout);
|
||||
const sample = report.samples?.[0] ?? {
|
||||
timestamp: report.timestamp,
|
||||
beforeGc: report.beforeGc,
|
||||
afterGc: report.afterGc,
|
||||
afterRequest: report.afterRequest,
|
||||
};
|
||||
|
||||
return {
|
||||
...sample,
|
||||
label,
|
||||
round,
|
||||
orderIndex,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = resolve(baseDirArg);
|
||||
const headDir = resolve(headDirArg);
|
||||
const baseOutput = resolve(baseOutputArg);
|
||||
const headOutput = resolve(headOutputArg);
|
||||
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const repos = {
|
||||
base: {
|
||||
dir: baseDir,
|
||||
samples: [],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of ['base', 'head']) {
|
||||
await measureRepo(label, repos[label].dir, -round, 0);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] : ['head', 'base'];
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const [orderIndex, label] of order.entries()) {
|
||||
const sample = await measureRepo(label, repos[label].dir, round, orderIndex);
|
||||
repos[label].samples.push(sample);
|
||||
}
|
||||
}
|
||||
|
||||
for (const label of ['base', 'head']) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: repos[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
...summarizeSamples(repos[label].samples),
|
||||
samples: repos[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts';
|
||||
|
||||
const phases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: any;
|
||||
aggregation: string;
|
||||
measurement: {
|
||||
startupTimeoutMs: any;
|
||||
memorySettleTimeMs: any;
|
||||
ipcTimeoutMs: any;
|
||||
requestCount: any;
|
||||
heapSnapshot: {
|
||||
enabled: any;
|
||||
timeoutMs: any;
|
||||
breakdownTopN: any;
|
||||
};
|
||||
};
|
||||
summary: Record<typeof phases[number], {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: {
|
||||
categories: Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
nodeCounts: Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
breakdowns?: Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
|
||||
};
|
||||
}>;
|
||||
samples: (MemoryReportRaw['samples'][number] & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
|
||||
async function resetState(repoDir: string) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
const pg = require('pg');
|
||||
const Redis = require('ioredis');
|
||||
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
|
||||
const breakdowns = {} as Record<typeof util.heapSnapshotCategories[number], Record<string, number>>;
|
||||
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
if (category === 'Total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
for (const childKey of childKeys) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
|
||||
.filter(value => Number.isFinite(value));
|
||||
|
||||
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
|
||||
}
|
||||
|
||||
if (Object.keys(categoryBreakdown).length > 0) {
|
||||
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
|
||||
}
|
||||
}
|
||||
|
||||
return breakdowns;
|
||||
}
|
||||
|
||||
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||
const otherValue = entries
|
||||
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const collapsed = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) collapsed.Other = otherValue;
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotCategoryValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotNodeCountValues = {} as Record<typeof util.heapSnapshotCategories[number], number>;
|
||||
for (const category of util.heapSnapshotCategories) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
|
||||
}
|
||||
|
||||
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
|
||||
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
|
||||
|
||||
summary[phase].heapSnapshot = {
|
||||
categories: heapSnapshotCategoryValues,
|
||||
nodeCounts: heapSnapshotNodeCountValues,
|
||||
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureRepo(label: string, repoDir: string, round: number) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
const measureEnv = {
|
||||
...process.env,
|
||||
MK_MEMORY_SAMPLE_COUNT: '1',
|
||||
} as NodeJS.ProcessEnv;
|
||||
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
|
||||
|
||||
const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], {
|
||||
cwd: repoDir,
|
||||
env: measureEnv,
|
||||
});
|
||||
|
||||
const report = JSON.parse(stdout) as MemoryReportRaw;
|
||||
const sample = report.samples[0];
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = resolve(baseDirArg);
|
||||
const headDir = resolve(headDirArg);
|
||||
const baseOutput = resolve(baseOutputArg);
|
||||
const headOutput = resolve(headOutputArg);
|
||||
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of ['base', 'head'] as const) {
|
||||
await measureRepo(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const [orderIndex, label] of order.entries()) {
|
||||
const sample = await measureRepo(label, reports[label].dir, round);
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const label of ['base', 'head'] as const) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summarizeSamples(reports[label].samples),
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
179
.github/scripts/utility.mts
vendored
179
.github/scripts/utility.mts
vendored
|
|
@ -1,179 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const heapSnapshotCategories = [
|
||||
'Total',
|
||||
'Code',
|
||||
'Strings',
|
||||
'JS arrays',
|
||||
'Typed arrays',
|
||||
'System objects',
|
||||
'Other JS objects',
|
||||
'Other non-JS objects',
|
||||
] as const;
|
||||
|
||||
export function 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) return null;
|
||||
|
||||
const center = median(values);
|
||||
return median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
export function normalizePath(filePath: string) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileSize(filePath: string) {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* traverseDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeLatex(text: string) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
export function formatColoredDelta(text: string, delta: number) {
|
||||
if (delta === 0) return text;
|
||||
const color = delta > 0 ? 'orange' : 'green';
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (value === 0) return '0 B';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaNumber(before: number, after: number) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
|
||||
}
|
||||
|
||||
export function formatDeltaBytes(deltaBytes: number) {
|
||||
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaBytes(before: number, after: number) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaBytes(delta);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(deltaPercent: number) {
|
||||
if (deltaPercent === 0) return '0%';
|
||||
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaPercent(before: number, after: number) {
|
||||
if (before == null || before === 0 || after == null || after === 0) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaPercent(delta / before * 100);
|
||||
}
|
||||
|
||||
export function commandName(command: string) {
|
||||
if (process.platform !== 'win32') return command;
|
||||
if (command === 'pnpm') return 'pnpm.cmd';
|
||||
return command;
|
||||
}
|
||||
|
||||
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(commandName(command), args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', data => {
|
||||
stdout += data;
|
||||
if (options.logStdout) process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data;
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout);
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -25,8 +25,7 @@ on:
|
|||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-js-size.mts
|
||||
- .github/scripts/frontend-js-size.mjs
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
|
|
|
|||
20
.github/workflows/frontend-bundle-report.yml
vendored
20
.github/workflows/frontend-bundle-report.yml
vendored
|
|
@ -20,8 +20,7 @@ on:
|
|||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-js-size.mts
|
||||
- .github/scripts/frontend-js-size.mjs
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
|
|
@ -102,6 +101,7 @@ jobs:
|
|||
working-directory: before
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
|
|
@ -120,21 +120,10 @@ jobs:
|
|||
working-directory: after
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
||||
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-visualizer
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate report markdown
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
shell: bash
|
||||
|
|
@ -142,10 +131,9 @@ jobs:
|
|||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
||||
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
|
|
|
|||
16
.github/workflows/get-backend-memory.yml
vendored
16
.github/workflows/get-backend-memory.yml
vendored
|
|
@ -9,12 +9,7 @@ on:
|
|||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/backend-memory-report.mts
|
||||
- .github/scripts/measure-backend-memory-comparison.mts
|
||||
- .github/scripts/backend-js-footprint.mjs
|
||||
- .github/scripts/backend-js-footprint-loader.mjs
|
||||
- .github/scripts/backend-js-footprint-require.cjs
|
||||
- .github/scripts/backend-memory-report.mjs
|
||||
- .github/workflows/get-backend-memory.yml
|
||||
- .github/workflows/report-backend-memory.yml
|
||||
|
||||
|
|
@ -94,12 +89,7 @@ jobs:
|
|||
env:
|
||||
MK_MEMORY_COMPARE_ROUNDS: 5
|
||||
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
|
||||
MK_MEMORY_HEAP_SNAPSHOT: 1
|
||||
run: node head/.github/scripts/measure-backend-memory-comparison.mts base head memory-base.json memory-head.json
|
||||
- name: Measure backend loaded JS footprint
|
||||
run: |
|
||||
node head/.github/scripts/backend-js-footprint.mjs base js-footprint-base.json
|
||||
node head/.github/scripts/backend-js-footprint.mjs head js-footprint-head.json
|
||||
run: node head/.github/scripts/measure-backend-memory-comparison.mjs base head memory-base.json memory-head.json
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
|
@ -107,8 +97,6 @@ jobs:
|
|||
path: |
|
||||
memory-base.json
|
||||
memory-head.json
|
||||
js-footprint-base.json
|
||||
js-footprint-head.json
|
||||
|
||||
save-pr-number:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
6
.github/workflows/report-backend-memory.yml
vendored
6
.github/workflows/report-backend-memory.yml
vendored
|
|
@ -53,13 +53,9 @@ jobs:
|
|||
run: cat ./artifacts/memory-base.json
|
||||
- name: Output head
|
||||
run: cat ./artifacts/memory-head.json
|
||||
- name: Output base JS footprint
|
||||
run: cat ./artifacts/js-footprint-base.json
|
||||
- name: Output head JS footprint
|
||||
run: cat ./artifacts/js-footprint-head.json
|
||||
- id: build-comment
|
||||
name: Build memory comment
|
||||
run: node .github/scripts/backend-memory-report.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md ./artifacts/js-footprint-base.json ./artifacts/js-footprint-head.json
|
||||
run: node .github/scripts/backend-memory-report.mjs ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md
|
||||
- uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
||||
|
|
|
|||
35
.github/workflows/test-backend.yml
vendored
35
.github/workflows/test-backend.yml
vendored
|
|
@ -19,6 +19,12 @@ on:
|
|||
- .github/workflows/test-backend.yml
|
||||
- .github/misskey/test.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_ffmpeg_cache_update:
|
||||
description: 'Force update ffmpeg cache'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
|
|
@ -56,9 +62,36 @@ jobs:
|
|||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
- name: Setup and Restore ffmpeg/ffprobe Cache
|
||||
id: cache-ffmpeg
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bin/ffmpeg
|
||||
/usr/local/bin/ffprobe
|
||||
# daily cache
|
||||
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
- name: Install FFmpeg
|
||||
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
for i in {1..3}; do
|
||||
echo "Attempt $i: Installing FFmpeg..."
|
||||
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
|
||||
tar -xf ffmpeg.tar.xz && \
|
||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
|
||||
break || sleep 10
|
||||
if [ $i -eq 3 ]; then
|
||||
echo "Failed to install FFmpeg after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
|
|
|||
35
.github/workflows/test-federation.yml
vendored
35
.github/workflows/test-federation.yml
vendored
|
|
@ -15,6 +15,12 @@ on:
|
|||
- packages/misskey-js/**
|
||||
- .github/workflows/test-federation.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_ffmpeg_cache_update:
|
||||
description: 'Force update ffmpeg cache'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
@ -31,9 +37,36 @@ jobs:
|
|||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
- name: Setup and Restore ffmpeg/ffprobe Cache
|
||||
id: cache-ffmpeg
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bin/ffmpeg
|
||||
/usr/local/bin/ffprobe
|
||||
# daily cache
|
||||
key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }}
|
||||
- name: Install FFmpeg
|
||||
if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
for i in {1..3}; do
|
||||
echo "Attempt $i: Installing FFmpeg..."
|
||||
curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
|
||||
tar -xf ffmpeg.tar.xz && \
|
||||
mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \
|
||||
mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||
rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \
|
||||
break || sleep 10
|
||||
if [ $i -eq 3 ]; then
|
||||
echo "Failed to install FFmpeg after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
22.18.0
|
||||
22.15.0
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
## Unreleased
|
||||
|
||||
### General
|
||||
-
|
||||
- Feat: 外部 OpenID Connect プロバイダー (Authentik 等) を用いた SSO ログインに対応 (設定ファイルの `sso.oidc`)
|
||||
- Feat: 既存のアカウントの設定画面から外部 OIDC アカウントを連携・解除できるように
|
||||
|
||||
### Client
|
||||
-
|
||||
- Enhance: ノートのフッターに「詳細」「リモートで表示」ボタンを追加 (メニューから移動)
|
||||
|
||||
### Server
|
||||
-
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
_lang_: "English"
|
||||
headlineMisskey: "A network connected by notes"
|
||||
introMisskey: "Welcome! Misskey is an open source, decentralized microblogging service.\nCreate \"notes\" to share your thoughts with everyone around you. 📡\nWith \"reactions\", you can also quickly express your feelings about everyone's notes. 👍\nLet's explore a new world! 🚀"
|
||||
poweredByMisskeyDescription: "{name} is one of the services powered by the open source platform <b>Misskey</b> (referred to as a \"Misskey instance\")."
|
||||
poweredByMisskeyDescription: '{name} is one of the services powered by the open source platform <b>Misskey</b> (referred to as a "Misskey instance").'
|
||||
monthAndDay: "{month}/{day}"
|
||||
search: "Search"
|
||||
reset: "Reset"
|
||||
|
|
@ -81,7 +81,7 @@ import: "Import"
|
|||
export: "Export"
|
||||
files: "Files"
|
||||
download: "Download"
|
||||
driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted."
|
||||
driveFileDeleteConfirm: 'Are you sure you want to delete "{name}"? All notes with this file attached will also be deleted.'
|
||||
unfollowConfirm: "Are you sure you want to unfollow {name}?"
|
||||
cancelFollowRequestConfirm: "Are you sure that you want to cancel your follow request to {name}?"
|
||||
rejectFollowRequestConfirm: "Are you sure that you want to reject the follow request from {name}?"
|
||||
|
|
@ -138,7 +138,7 @@ pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when
|
|||
emojiPickerDisplay: "Emoji picker display"
|
||||
overwriteFromPinnedEmojisForReaction: "Override from reaction settings"
|
||||
overwriteFromPinnedEmojis: "Override from general settings"
|
||||
reactionSettingDescription2: "Drag to reorder, click to delete, press \"+\" to add."
|
||||
reactionSettingDescription2: 'Drag to reorder, click to delete, press "+" to add.'
|
||||
rememberNoteVisibility: "Remember note visibility settings"
|
||||
attachCancel: "Remove attachment"
|
||||
deleteFile: "Delete file"
|
||||
|
|
@ -287,8 +287,8 @@ announcements: "Announcements"
|
|||
imageUrl: "Image URL"
|
||||
remove: "Delete"
|
||||
removed: "Successfully deleted"
|
||||
removeAreYouSure: "Are you sure that you want to remove \"{x}\"?"
|
||||
deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?"
|
||||
removeAreYouSure: 'Are you sure that you want to remove "{x}"?'
|
||||
deleteAreYouSure: 'Are you sure that you want to delete "{x}"?'
|
||||
resetAreYouSure: "Really reset?"
|
||||
areYouSure: "Are you sure?"
|
||||
saved: "Saved"
|
||||
|
|
@ -331,7 +331,7 @@ dark: "Dark"
|
|||
lightThemes: "Light themes"
|
||||
darkThemes: "Dark themes"
|
||||
syncDeviceDarkMode: "Sync Dark Mode with your device settings"
|
||||
switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" is turned on. Would you like to turn off synchronization and switch modes manually?"
|
||||
switchDarkModeManuallyWhenSyncEnabledConfirm: '"{x}" is turned on. Would you like to turn off synchronization and switch modes manually?'
|
||||
drive: "Drive"
|
||||
fileName: "Filename"
|
||||
selectFile: "Select a file"
|
||||
|
|
@ -399,7 +399,7 @@ bannerUrl: "Banner image URL"
|
|||
backgroundImageUrl: "Background image URL"
|
||||
basicInfo: "Basic info"
|
||||
pinnedUsers: "Pinned users"
|
||||
pinnedUsersDescription: "List usernames separated by line breaks to be pinned in the \"Explore\" tab."
|
||||
pinnedUsersDescription: 'List usernames separated by line breaks to be pinned in the "Explore" tab.'
|
||||
pinnedPages: "Pinned Pages"
|
||||
pinnedPagesDescription: "Enter the paths of the Pages you want to pin to the top page of this instance, separated by line breaks."
|
||||
pinnedClipId: "ID of the clip to pin"
|
||||
|
|
@ -475,7 +475,7 @@ unregister: "Unregister"
|
|||
passwordLessLogin: "Password-less login"
|
||||
passwordLessLoginDescription: "Allows password-less login using a security- or passkey only"
|
||||
resetPassword: "Reset password"
|
||||
newPasswordIs: "The new password is \"{password}\""
|
||||
newPasswordIs: 'The new password is "{password}"'
|
||||
reduceUiAnimation: "Reduce UI animations"
|
||||
share: "Share"
|
||||
notFound: "Not found"
|
||||
|
|
@ -576,7 +576,7 @@ objectStorageUseSSL: "Use SSL"
|
|||
objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections"
|
||||
objectStorageUseProxy: "Connect over Proxy"
|
||||
objectStorageUseProxyDesc: "Turn this off if you are not going to use a Proxy for API connections"
|
||||
objectStorageSetPublicRead: "Set \"public-read\" on upload"
|
||||
objectStorageSetPublicRead: 'Set "public-read" on upload'
|
||||
s3ForcePathStyleDesc: "If s3ForcePathStyle is enabled, the bucket name has to included in the path of the URL as opposed to the hostname of the URL. You may need to enable this setting when using services such as a self-hosted Minio instance."
|
||||
serverLogs: "Server logs"
|
||||
deleteAll: "Delete all"
|
||||
|
|
@ -703,7 +703,7 @@ regexpError: "Regular Expression error"
|
|||
regexpErrorDescription: "An error occurred in the regular expression on line {line} of your {tab} word mutes:"
|
||||
instanceMute: "Instance Mutes"
|
||||
userSaysSomething: "{name} said something"
|
||||
userSaysSomethingAbout: "{name} said something about \"{word}\""
|
||||
userSaysSomethingAbout: '{name} said something about "{word}"'
|
||||
makeActive: "Activate"
|
||||
display: "Display"
|
||||
copy: "Copy"
|
||||
|
|
@ -752,7 +752,7 @@ createNew: "Create new"
|
|||
optional: "Optional"
|
||||
createNewClip: "Create new clip"
|
||||
unclip: "Unclip"
|
||||
confirmToUnclipAlreadyClippedNote: "This note is already part of the \"{name}\" clip. Do you want to remove it from this clip instead?"
|
||||
confirmToUnclipAlreadyClippedNote: 'This note is already part of the "{name}" clip. Do you want to remove it from this clip instead?'
|
||||
removeFromAntenna: "Remove from this antenna"
|
||||
removeNoteFromAntennaConfirm: "Are you sure you want to remove this note from {name}?"
|
||||
public: "Public"
|
||||
|
|
@ -777,7 +777,7 @@ driveFilesCount: "Number of Drive files"
|
|||
driveUsage: "Drive space usage"
|
||||
noCrawle: "Reject crawler indexing"
|
||||
noCrawleDescription: "Ask search engines to not index your profile page, notes, Pages, etc."
|
||||
lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", your notes will be visible to anyone, even if you require followers to be manually approved."
|
||||
lockedAccountInfo: 'Unless you set your note visiblity to "Followers only", your notes will be visible to anyone, even if you require followers to be manually approved.'
|
||||
alwaysMarkSensitive: "Mark as sensitive by default"
|
||||
loadRawImages: "Load original images instead of showing thumbnails"
|
||||
disableShowingAnimatedImages: "Don't play animated images"
|
||||
|
|
@ -796,8 +796,8 @@ experimentalFeatures: "Experimental features"
|
|||
experimental: "Experimental"
|
||||
thisIsExperimentalFeature: "This is an experimental feature. Its functionality is subject to change, and it may not operate as intended."
|
||||
developer: "Developer"
|
||||
makeExplorable: "Make account visible in \"Explore\""
|
||||
makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section."
|
||||
makeExplorable: 'Make account visible in "Explore"'
|
||||
makeExplorableDescription: 'If you turn this off, your account will not show up in the "Explore" section.'
|
||||
duplicate: "Duplicate"
|
||||
left: "Left"
|
||||
center: "Center"
|
||||
|
|
@ -852,7 +852,7 @@ unlikeConfirm: "Really remove your like?"
|
|||
fullView: "Full view"
|
||||
quitFullView: "Exit full view"
|
||||
addDescription: "Add description"
|
||||
userPagePinTip: "You can display notes here by selecting \"Pin to profile\" from the menu of individual notes."
|
||||
userPagePinTip: 'You can display notes here by selecting "Pin to profile" from the menu of individual notes.'
|
||||
notSpecifiedMentionWarning: "This note contains mentions of users not included as recipients"
|
||||
info: "About"
|
||||
userInfo: "User information"
|
||||
|
|
@ -942,7 +942,7 @@ continueThread: "View thread continuation"
|
|||
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
|
||||
incorrectPassword: "Incorrect password."
|
||||
incorrectTotp: "The one-time password is incorrect or has expired."
|
||||
voteConfirm: "Confirm your vote for \"{choice}\"?"
|
||||
voteConfirm: 'Confirm your vote for "{choice}"?'
|
||||
hide: "Hide"
|
||||
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
|
||||
welcomeBackWithName: "Welcome back, {name}"
|
||||
|
|
@ -1098,7 +1098,7 @@ nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from
|
|||
rolesAssignedToMe: "Roles assigned to me"
|
||||
resetPasswordConfirm: "Really reset your password?"
|
||||
sensitiveWords: "Sensitive words"
|
||||
sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks."
|
||||
sensitiveWordsDescription: 'The visibility of all notes containing any of the configured words will be set to "Home" automatically. You can list multiple by separating them via line breaks.'
|
||||
sensitiveWordsDescription2: "Using spaces will create AND expressions and surrounding keywords with slashes will turn them into a regular expression."
|
||||
prohibitedWords: "Prohibited words"
|
||||
prohibitedWordsDescription: "Enables an error when attempting to post a note containing the set word(s). Multiple words can be set, separated by a new line."
|
||||
|
|
@ -1117,7 +1117,7 @@ retryAllQueuesConfirmText: "This will temporarily increase the server load."
|
|||
enableChartsForRemoteUser: "Generate remote user data charts"
|
||||
enableChartsForFederatedInstances: "Generate remote instance data charts"
|
||||
enableStatsForFederatedInstances: "Receive remote server stats"
|
||||
showClipButtonInNoteFooter: "Add \"Clip\" to note action menu"
|
||||
showClipButtonInNoteFooter: 'Add "Clip" to note action menu'
|
||||
reactionsDisplaySize: "Reaction display size"
|
||||
limitWidthOfReaction: "Limit the maximum width of reactions and display them in reduced size."
|
||||
noteIdOrUrl: "Note ID or URL"
|
||||
|
|
@ -1161,7 +1161,7 @@ displayOfNote: "Note display"
|
|||
initialAccountSetting: "Profile setup"
|
||||
youFollowing: "Followed"
|
||||
preventAiLearning: "Reject usage in Machine Learning (Generative AI)"
|
||||
preventAiLearningDescription: "Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a \"noai\" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored."
|
||||
preventAiLearningDescription: 'Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a "noai" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored.'
|
||||
options: "Options"
|
||||
specifyUser: "Specific user"
|
||||
lookupConfirm: "Do you want to look up?"
|
||||
|
|
@ -1202,7 +1202,7 @@ used: "Used"
|
|||
expired: "Expired"
|
||||
doYouAgree: "Agree?"
|
||||
beSureToReadThisAsItIsImportant: "Please read this important information."
|
||||
iHaveReadXCarefullyAndAgree: "I have read the text \"{x}\" and agree."
|
||||
iHaveReadXCarefullyAndAgree: 'I have read the text "{x}" and agree.'
|
||||
dialog: "Dialog"
|
||||
icon: "Icon"
|
||||
forYou: "For you"
|
||||
|
|
@ -1261,7 +1261,7 @@ refreshing: "Refreshing..."
|
|||
pullDownToRefresh: "Pull down to refresh"
|
||||
useGroupedNotifications: "Display grouped notifications"
|
||||
emailVerificationFailedError: "A problem occurred while verifying your email address. The link may have expired."
|
||||
cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided."
|
||||
cwNotationRequired: 'If "Hide content" is enabled, a description must be provided.'
|
||||
doReaction: "Add reaction"
|
||||
code: "Code"
|
||||
reloadRequiredToApplySettings: "Reloading is required to apply the settings."
|
||||
|
|
@ -1313,6 +1313,7 @@ modified: "Modified"
|
|||
discard: "Discard"
|
||||
thereAreNChanges: "There are {n} change(s)"
|
||||
signinWithPasskey: "Sign in with Passkey"
|
||||
signinWithSso: "Sign in with SSO"
|
||||
unknownWebAuthnKey: "Unknown Passkey"
|
||||
passkeyVerificationFailed: "Passkey verification has failed."
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled."
|
||||
|
|
@ -1333,7 +1334,7 @@ federationDisabled: "Federation is disabled on this server. You cannot interact
|
|||
draft: "Drafts"
|
||||
draftsAndScheduledNotes: "Drafts and scheduled notes"
|
||||
confirmOnReact: "Confirm when reacting"
|
||||
reactAreYouSure: "Would you like to add a \"{emoji}\" reaction?"
|
||||
reactAreYouSure: 'Would you like to add a "{emoji}" reaction?'
|
||||
markAsSensitiveConfirm: "Do you want to set this media as sensitive?"
|
||||
unmarkAsSensitiveConfirm: "Do you want to remove the sensitive designation for this media?"
|
||||
preferences: "Preferences"
|
||||
|
|
@ -1385,7 +1386,7 @@ unmuteX: "Unmute {x}"
|
|||
abort: "Abort"
|
||||
tip: "Tips & Tricks"
|
||||
redisplayAllTips: "Show all “Tips & Tricks” again"
|
||||
hideAllTips: "Hide all \"Tips & Tricks\""
|
||||
hideAllTips: 'Hide all "Tips & Tricks"'
|
||||
defaultImageCompressionLevel: "Default image compression level"
|
||||
defaultImageCompressionLevel_description: "Lower level preserves image quality but increases file size.<br>Higher level reduce file size, but reduce image quality."
|
||||
defaultCompressionLevel: "Default compression level"
|
||||
|
|
@ -1573,7 +1574,7 @@ _settings:
|
|||
_preferencesProfile:
|
||||
profileName: "Profile name"
|
||||
profileNameDescription: "Set a name that identifies this device."
|
||||
profileNameDescription2: "Example: \"Main PC\", \"Smartphone\""
|
||||
profileNameDescription2: 'Example: "Main PC", "Smartphone"'
|
||||
manageProfiles: "Manage Profiles"
|
||||
shareSameProfileBetweenDevicesIsNotRecommended: "We do not recommend sharing the same profile across multiple devices."
|
||||
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "If there are settings you wish to synchronize across multiple devices, enable the “Synchronize across multiple devices” option individually for each device."
|
||||
|
|
@ -1636,11 +1637,11 @@ _announcement:
|
|||
forExistingUsers: "Existing users only"
|
||||
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."
|
||||
needConfirmationToRead: "Require separate read confirmation"
|
||||
needConfirmationToReadDescription: "A separate prompt to confirm marking this announcement as read will be displayed if enabled. This announcement will also be excluded from any \"Mark all as read\" functionality."
|
||||
needConfirmationToReadDescription: 'A separate prompt to confirm marking this announcement as read will be displayed if enabled. This announcement will also be excluded from any "Mark all as read" functionality.'
|
||||
end: "Archive announcement"
|
||||
tooManyActiveAnnouncementDescription: "Having too many active announcements may worsen the user experience. Please consider archiving announcements that have become obsolete."
|
||||
readConfirmTitle: "Mark as read?"
|
||||
readConfirmText: "This will mark the contents of \"{title}\" as read."
|
||||
readConfirmText: 'This will mark the contents of "{title}" as read.'
|
||||
shouldNotBeUsedToPresentPermanentInfo: "It's best to use announcements to publish fresh and time-bound information, not for information that will be relevant in the long term."
|
||||
dialogAnnouncementUxWarn: "Having two or more dialog-style notifications simultaneously can significantly impact the user experience, so please use them carefully."
|
||||
silence: "No notification"
|
||||
|
|
@ -1652,7 +1653,7 @@ _initialAccountSetting:
|
|||
profileSetting: "Profile settings"
|
||||
privacySetting: "Privacy settings"
|
||||
theseSettingsCanEditLater: "You can always change these settings later."
|
||||
youCanEditMoreSettingsInSettingsPageLater: "There are many more settings you can configure from the \"Settings\" page. Be sure to visit it later."
|
||||
youCanEditMoreSettingsInSettingsPageLater: 'There are many more settings you can configure from the "Settings" page. Be sure to visit it later.'
|
||||
followUsers: "Try following some users that interest you to build up your timeline."
|
||||
pushNotificationDescription: "Enabling push notifications will allow you to receive notifications from {name} directly on your device."
|
||||
initialAccountSettingCompleted: "Profile setup complete!"
|
||||
|
|
@ -1706,18 +1707,18 @@ _initialTutorial:
|
|||
localOnly: "Posting with this flag will not federate the note to other servers. Users on other servers will not be able to view these notes directly, regardless of the display settings above."
|
||||
_cw:
|
||||
title: "Content Warning"
|
||||
description: "Instead of the body, the content written in 'comments' field will be displayed. Pressing \"read more\" will reveal the body."
|
||||
description: 'Instead of the body, the content written in ''comments'' field will be displayed. Pressing "read more" will reveal the body.'
|
||||
_exampleNote:
|
||||
cw: "This will surely make you hungry!"
|
||||
note: "Just had a chocolate-glazed donut 🍩😋"
|
||||
useCases: "This is used when following the server guidelines, for necessary notes, or for self-restriction of spoiler or sensitive text."
|
||||
_howToMakeAttachmentsSensitive:
|
||||
title: "How to Mark Attachments as Sensitive?"
|
||||
description: "For attachments that are required by server guidelines or that should not be left intact, add a \"sensitive\" flag."
|
||||
description: 'For attachments that are required by server guidelines or that should not be left intact, add a "sensitive" flag.'
|
||||
tryThisFile: "Try marking the image attached in this form as sensitive!"
|
||||
_exampleNote:
|
||||
note: "Oops, messed up opening the natto lid..."
|
||||
method: "To mark an attachment as sensitive, click the file thumbnail, open the menu, and click \"Mark as Sensitive.\""
|
||||
method: 'To mark an attachment as sensitive, click the file thumbnail, open the menu, and click "Mark as Sensitive."'
|
||||
sensitiveSucceeded: "When attaching files, please set sensitivities in accordance with the server guidelines."
|
||||
doItToContinue: "Mark the attachment file as sensitive to proceed."
|
||||
_done:
|
||||
|
|
@ -1952,7 +1953,7 @@ _achievements:
|
|||
description: "Look at your list of achievements for at least 3 minutes"
|
||||
_iLoveMisskey:
|
||||
title: "I Love Misskey"
|
||||
description: "Post \"I ❤ #Misskey\""
|
||||
description: 'Post "I ❤ #Misskey"'
|
||||
flavor: "Misskey's development team greatly appreciates your support!"
|
||||
_foundTreasure:
|
||||
title: "Treasure Hunt"
|
||||
|
|
@ -1961,7 +1962,7 @@ _achievements:
|
|||
title: "Short break"
|
||||
description: "Keep Misskey opened for at least 30 minutes"
|
||||
_client60min:
|
||||
title: "No \"Miss\" in Misskey"
|
||||
title: 'No "Miss" in Misskey'
|
||||
description: "Keep Misskey opened for at least 60 minutes"
|
||||
_noteDeletedWithin1min:
|
||||
title: "Nevermind"
|
||||
|
|
@ -1985,7 +1986,7 @@ _achievements:
|
|||
description: "View your instance's charts"
|
||||
_outputHelloWorldOnScratchpad:
|
||||
title: "Hello, world!"
|
||||
description: "Output \"hello world\" in the Scratchpad"
|
||||
description: 'Output "hello world" in the Scratchpad'
|
||||
_open3windows:
|
||||
title: "Multi-Window"
|
||||
description: "Have at least 3 windows open at the same time"
|
||||
|
|
@ -2003,7 +2004,7 @@ _achievements:
|
|||
description: "Has a chance to be obtained with a probability of 0.005% every 10 seconds"
|
||||
_setNameToSyuilo:
|
||||
title: "God Complex"
|
||||
description: "Set your name to \"syuilo\""
|
||||
description: 'Set your name to "syuilo"'
|
||||
_passedSinceAccountCreated1:
|
||||
title: "One Year Anniversary"
|
||||
description: "One year has passed since your account was created"
|
||||
|
|
@ -2132,7 +2133,7 @@ _role:
|
|||
isBot: "Bot Users"
|
||||
isSuspended: "Suspended user"
|
||||
isLocked: "Private accounts"
|
||||
isExplorable: "Effective user of \"make an account discoverable\""
|
||||
isExplorable: 'Effective user of "make an account discoverable"'
|
||||
createdLessThan: "Less than X has passed since account creation"
|
||||
createdMoreThan: "More than X has passed since account creation"
|
||||
followersLessThanOrEq: "Has X or fewer followers"
|
||||
|
|
@ -2211,12 +2212,12 @@ _preferencesBackups:
|
|||
save: "Save changes"
|
||||
inputName: "Please enter a name for this backup"
|
||||
cannotSave: "Saving failed"
|
||||
nameAlreadyExists: "A backup called \"{name}\" already exists. Please enter a different name."
|
||||
applyConfirm: "Do you really want to apply the \"{name}\" backup to this device? Existing settings of this device will be overwritten."
|
||||
nameAlreadyExists: 'A backup called "{name}" already exists. Please enter a different name.'
|
||||
applyConfirm: 'Do you really want to apply the "{name}" backup to this device? Existing settings of this device will be overwritten.'
|
||||
saveConfirm: "Save backup as {name}?"
|
||||
deleteConfirm: "Delete the {name} backup?"
|
||||
renameConfirm: "Rename this backup from \"{old}\" to \"{new}\"?"
|
||||
noBackups: "No backups exist. You may backup your client settings on this server by using \"Create new backup\"."
|
||||
renameConfirm: 'Rename this backup from "{old}" to "{new}"?'
|
||||
noBackups: 'No backups exist. You may backup your client settings on this server by using "Create new backup".'
|
||||
createdAt: "Created at: {date} {time}"
|
||||
updatedAt: "Updated at: {date} {time}"
|
||||
cannotLoad: "Loading failed"
|
||||
|
|
@ -2413,6 +2414,20 @@ _2fa:
|
|||
backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentification as soon as possible if you are no longer able to use it."
|
||||
backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentification app, you will be unable to access this account. Please reconfigure two-factor authentification."
|
||||
moreDetailedGuideHere: "Here is detailed guide"
|
||||
_sso:
|
||||
connectedAccounts: "Connected external accounts"
|
||||
description: "Linking an external identity provider (OIDC) account to this account lets you sign in with that account as well."
|
||||
link: "Link an external account"
|
||||
linkProvider: "Link with {name}"
|
||||
unlink: "Unlink"
|
||||
unlinkConfirm: "Unlink this external account? You will no longer be able to sign in with it afterwards."
|
||||
noLinkedAccounts: "There are no linked external accounts."
|
||||
lastUsedAt: "Last used"
|
||||
linked: "External account linked."
|
||||
unlinked: "Unlinked."
|
||||
linkFailed: "Failed to link the external account."
|
||||
alreadyLinkedToOther: "This external account is already linked to another account."
|
||||
backToSecuritySettings: "Back to security settings"
|
||||
_permissions:
|
||||
"read:account": "View your account information"
|
||||
"write:account": "Edit your account information"
|
||||
|
|
@ -2502,7 +2517,7 @@ _permissions:
|
|||
"read:chat": "Browse Chat"
|
||||
_auth:
|
||||
shareAccessTitle: "Granting application permissions"
|
||||
shareAccess: "Would you like to authorize \"{name}\" to access this account?"
|
||||
shareAccess: 'Would you like to authorize "{name}" to access this account?'
|
||||
shareAccessAsk: "Are you sure you want to authorize this application to access your account?"
|
||||
permission: "{name} requests the following permissions"
|
||||
permissionAsk: "This application requests the following permissions"
|
||||
|
|
@ -2821,7 +2836,7 @@ _notification:
|
|||
exportOfXCompleted: "Export of {x} has been completed"
|
||||
login: "Someone logged in"
|
||||
createToken: "An access token has been created"
|
||||
createTokenDescription: "If you have no idea, delete the access token through \"{text}\"."
|
||||
createTokenDescription: 'If you have no idea, delete the access token through "{text}".'
|
||||
_types:
|
||||
all: "All"
|
||||
note: "New notes"
|
||||
|
|
@ -2868,9 +2883,9 @@ _deck:
|
|||
deleteProfile: "Delete profile"
|
||||
introduction: "Create the perfect interface for you by arranging columns freely!"
|
||||
introduction2: "Click on the + on the right of the screen to add new columns whenever you want."
|
||||
widgetsIntroduction: "Please select \"Edit widgets\" in the column menu and add a widget."
|
||||
widgetsIntroduction: 'Please select "Edit widgets" in the column menu and add a widget.'
|
||||
useSimpleUiForNonRootPages: "Use simple UI for navigated pages"
|
||||
usedAsMinWidthWhenFlexible: "Minimum width will be used for this when the \"Auto-adjust width\" option is enabled"
|
||||
usedAsMinWidthWhenFlexible: 'Minimum width will be used for this when the "Auto-adjust width" option is enabled'
|
||||
flexible: "Auto-adjust width"
|
||||
enableSyncBetweenDevicesForProfiles: "Enable profile information sync between devices"
|
||||
showHowToUse: ""
|
||||
|
|
@ -3184,8 +3199,8 @@ _customEmojisManager:
|
|||
_register:
|
||||
uploadSettingTitle: "Upload settings"
|
||||
uploadSettingDescription: "On this screen, you can configure the behavior when uploading Emojis."
|
||||
directoryToCategoryLabel: "Enter the directory name in the \"category\" field"
|
||||
directoryToCategoryCaption: "When you drag and drop a directory, enter the directory name in the \"category\" field."
|
||||
directoryToCategoryLabel: 'Enter the directory name in the "category" field'
|
||||
directoryToCategoryCaption: 'When you drag and drop a directory, enter the directory name in the "category" field.'
|
||||
confirmRegisterEmojisDescription: "Register the Emojis from the list as new custom Emojis. Are you sure to continue? (To avoid overload, only {count} Emoji(s) can be registered in a single operation)"
|
||||
confirmClearEmojisDescription: "Discard the edits and clear the Emojis from the list. Are you sure to continue?"
|
||||
confirmUploadEmojisDescription: "Upload the dragged and dropped {count} file(s) to the drive. Are you sure to continue?"
|
||||
|
|
@ -3205,7 +3220,7 @@ _embedCodeGen:
|
|||
codeGeneratedDescription: "Paste the generated code into your website to embed the content."
|
||||
_selfXssPrevention:
|
||||
warning: "WARNING"
|
||||
title: "\"Paste something on this screen\" is all a scam."
|
||||
title: '"Paste something on this screen" is all a scam.'
|
||||
description1: "If you paste something here, a malicious user could hijack your account or steal your personal information."
|
||||
description2: "If you do not understand exactly what you are trying to paste, %cstop working right now and close this window."
|
||||
description3: "For more information, please refer to this. {link}"
|
||||
|
|
@ -3290,7 +3305,7 @@ _serverSetupWizard:
|
|||
largeScaleServerAdvice: "Large servers may require advanced infrastructure knowledge, such as load balancing and database replication."
|
||||
doYouConnectToFediverse: "Do you want to connect to the Fediverse?"
|
||||
doYouConnectToFediverse_description1: "When connected to a network of distributed servers (Fediverse) content can be exchanged with other servers."
|
||||
doYouConnectToFediverse_description2: "Connecting with the Fediverse is also called \"federation\""
|
||||
doYouConnectToFediverse_description2: 'Connecting with the Fediverse is also called "federation"'
|
||||
youCanConfigureMoreFederationSettingsLater: "Advanced settings such as specifying federated servers can be configured later."
|
||||
remoteContentsCleaning: "Automatic cleanup of received contents"
|
||||
remoteContentsCleaning_description: "Federation may result in a continuous inflow of content. Enabling automatic cleanup will remove outdated and unreferenced content from the server to save storage."
|
||||
|
|
|
|||
|
|
@ -1313,6 +1313,7 @@ modified: "変更あり"
|
|||
discard: "破棄"
|
||||
thereAreNChanges: "{n}件の変更があります"
|
||||
signinWithPasskey: "パスキーでログイン"
|
||||
signinWithSso: "SSOでログイン"
|
||||
unknownWebAuthnKey: "登録されていないパスキーです。"
|
||||
passkeyVerificationFailed: "パスキーの検証に失敗しました。"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
|
||||
|
|
@ -2466,6 +2467,21 @@ _2fa:
|
|||
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
|
||||
moreDetailedGuideHere: "詳細なガイドはこちら"
|
||||
|
||||
_sso:
|
||||
connectedAccounts: "連携済みの外部アカウント"
|
||||
description: "外部のIDプロバイダー(OIDC)アカウントをこのアカウントに連携すると、そのアカウントでもログインできるようになります。"
|
||||
link: "外部アカウントを連携"
|
||||
linkProvider: "{name}と連携"
|
||||
unlink: "連携を解除"
|
||||
unlinkConfirm: "この外部アカウントの連携を解除しますか?解除後はこのアカウントでログインできなくなります。"
|
||||
noLinkedAccounts: "連携済みの外部アカウントはありません。"
|
||||
lastUsedAt: "最終使用"
|
||||
linked: "外部アカウントを連携しました。"
|
||||
unlinked: "連携を解除しました。"
|
||||
linkFailed: "外部アカウントの連携に失敗しました。"
|
||||
alreadyLinkedToOther: "この外部アカウントは既に別のアカウントに連携されています。"
|
||||
backToSecuritySettings: "セキュリティ設定に戻る"
|
||||
|
||||
_permissions:
|
||||
"read:account": "アカウントの情報を見る"
|
||||
"write:account": "アカウントの情報を変更する"
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ driveFileDeleteConfirm: "‘{name}’ 파일을 삭제하시겠습니까? 이
|
|||
unfollowConfirm: "{name}님을 언팔로우하시겠습니까?"
|
||||
cancelFollowRequestConfirm: "{name}(으)로의 팔로우 신청을 취소하시겠습니까?"
|
||||
rejectFollowRequestConfirm: "{name}(으)로부터의 팔로우 신청을 거부하시겠습니까?"
|
||||
exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다."
|
||||
exportRequested: '내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 "드라이브"에 추가됩니다.'
|
||||
importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다."
|
||||
lists: "리스트"
|
||||
noLists: "리스트가 없습니다"
|
||||
|
|
@ -287,8 +287,8 @@ announcements: "공지사항"
|
|||
imageUrl: "이미지 URL"
|
||||
remove: "삭제"
|
||||
removed: "삭제했습니다"
|
||||
removeAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?"
|
||||
deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?"
|
||||
removeAreYouSure: '"{x}" 을(를) 삭제하시겠습니까?'
|
||||
deleteAreYouSure: '"{x}" 을(를) 삭제하시겠습니까?'
|
||||
resetAreYouSure: "초기화 하시겠습니까?"
|
||||
areYouSure: "계속 진행하시겠습니까?"
|
||||
saved: "저장했습니다"
|
||||
|
|
@ -399,7 +399,7 @@ bannerUrl: "배너 이미지 URL"
|
|||
backgroundImageUrl: "배경 이미지 URL"
|
||||
basicInfo: "기본 정보"
|
||||
pinnedUsers: "고정한 유저"
|
||||
pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다."
|
||||
pinnedUsersDescription: '"발견하기" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다.'
|
||||
pinnedPages: "고정한 페이지"
|
||||
pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다."
|
||||
pinnedClipId: "고정할 클립의 ID"
|
||||
|
|
@ -475,7 +475,7 @@ unregister: "등록 해제"
|
|||
passwordLessLogin: "비밀번호 없이 로그인"
|
||||
passwordLessLoginDescription: "비밀번호 없이 보안 키 또는 패스키만 사용해서 로그인합니다."
|
||||
resetPassword: "비밀번호 재설정"
|
||||
newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다"
|
||||
newPasswordIs: '새로운 비밀번호는 "{password}" 입니다'
|
||||
reduceUiAnimation: "UI의 애니메이션을 줄이기"
|
||||
share: "공유"
|
||||
notFound: "찾을 수 없습니다"
|
||||
|
|
@ -703,7 +703,7 @@ regexpError: "정규 표현식 오류"
|
|||
regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했습니다:"
|
||||
instanceMute: "서버 뮤트"
|
||||
userSaysSomething: "{name}님이 무언가를 말했습니다"
|
||||
userSaysSomethingAbout: "{name}님이 \"{word}\"를 언급했습니다."
|
||||
userSaysSomethingAbout: '{name}님이 "{word}"를 언급했습니다.'
|
||||
makeActive: "활성화"
|
||||
display: "보기"
|
||||
copy: "복사"
|
||||
|
|
@ -797,7 +797,7 @@ experimental: "실험실"
|
|||
thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양이 변경되거나 정상적으로 동작하지 않을 가능성이 있습니다."
|
||||
developer: "개발자"
|
||||
makeExplorable: "계정을 쉽게 발견하도록 하기"
|
||||
makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다."
|
||||
makeExplorableDescription: '비활성화하면 "발견하기"에 나의 계정을 표시하지 않습니다.'
|
||||
duplicate: "복제"
|
||||
left: "왼쪽"
|
||||
center: "가운데"
|
||||
|
|
@ -942,7 +942,7 @@ continueThread: "글타래 더 보기"
|
|||
deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까? "
|
||||
incorrectPassword: "비밀번호가 올바르지 않습니다."
|
||||
incorrectTotp: "OTP 번호가 틀렸거나 유효기간이 만료되어 있을 수 있습니다."
|
||||
voteConfirm: "\"{choice}\"에 투표하시겠습니까?"
|
||||
voteConfirm: '"{choice}"에 투표하시겠습니까?'
|
||||
hide: "숨기기"
|
||||
useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시"
|
||||
welcomeBackWithName: "{name}님, 환영합니다."
|
||||
|
|
@ -1202,7 +1202,7 @@ used: "사용됨"
|
|||
expired: "만료됨"
|
||||
doYouAgree: "동의하십니까?"
|
||||
beSureToReadThisAsItIsImportant: "중요하므로 반드시 읽어주십시오."
|
||||
iHaveReadXCarefullyAndAgree: "\"{x}\"의 내용을 읽고 동의합니다."
|
||||
iHaveReadXCarefullyAndAgree: '"{x}"의 내용을 읽고 동의합니다.'
|
||||
dialog: "다이얼로그"
|
||||
icon: "아바타"
|
||||
forYou: "나에게"
|
||||
|
|
@ -1313,6 +1313,7 @@ modified: "변경 있음"
|
|||
discard: "파기"
|
||||
thereAreNChanges: "{n}건 변경이 있습니다."
|
||||
signinWithPasskey: "패스키로 로그인"
|
||||
signinWithSso: "SSO로 로그인"
|
||||
unknownWebAuthnKey: "등록되지 않은 패스키입니다."
|
||||
passkeyVerificationFailed: "패스키 검증을 실패했습니다."
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "입력된 패스키는 정상적이나, 비밀번호 없이 로그인 하는 기능이 비활성화 되어있습니다."
|
||||
|
|
@ -1333,7 +1334,7 @@ federationDisabled: "이 서버는 연합을 하지 않고 있습니다. 리모
|
|||
draft: "초안"
|
||||
draftsAndScheduledNotes: "초안과 예약 게시물"
|
||||
confirmOnReact: "리액션할 때 확인"
|
||||
reactAreYouSure: "\" {emoji} \"로 리액션하시겠습니까?"
|
||||
reactAreYouSure: '" {emoji} "로 리액션하시겠습니까?'
|
||||
markAsSensitiveConfirm: "이 미디어를 민감한 미디어로 설정하시겠습니까?"
|
||||
unmarkAsSensitiveConfirm: "이 미디어의 민감한 미디어 지정을 해제하시겠습니까?"
|
||||
preferences: "환경설정"
|
||||
|
|
@ -1952,7 +1953,7 @@ _achievements:
|
|||
description: "도전 과제 목록을 3분 이상 쳐다봤다"
|
||||
_iLoveMisskey:
|
||||
title: "I Love Misskey"
|
||||
description: "\"I ❤ #Misskey\"를 게시했다"
|
||||
description: '"I ❤ #Misskey"를 게시했다'
|
||||
flavor: "Misskey를 이용해 주셔서 감사합니다! ― 개발 팀"
|
||||
_foundTreasure:
|
||||
title: "보물찾기"
|
||||
|
|
@ -1961,7 +1962,7 @@ _achievements:
|
|||
title: "잠시 쉬어요"
|
||||
description: "클라이언트를 시작하고 30분이 경과했다"
|
||||
_client60min:
|
||||
title: "No \"Miss\" in Misskey"
|
||||
title: 'No "Miss" in Misskey'
|
||||
description: "클라이언트를 시작하고 60분이 경과했다"
|
||||
_noteDeletedWithin1min:
|
||||
title: "있었는데요 없었습니다"
|
||||
|
|
@ -2211,12 +2212,12 @@ _preferencesBackups:
|
|||
save: "현재 설정으로 덮어쓰기"
|
||||
inputName: "백업 이름을 입력하세요"
|
||||
cannotSave: "저장하지 못했습니다"
|
||||
nameAlreadyExists: "\"{name}\" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오."
|
||||
applyConfirm: "\"{name}\" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다."
|
||||
nameAlreadyExists: '"{name}" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오.'
|
||||
applyConfirm: '"{name}" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다.'
|
||||
saveConfirm: "{name} 백업을 덮어쓰시겠습니까?"
|
||||
deleteConfirm: "{name} 백업을 삭제하시겠습니까?"
|
||||
renameConfirm: "‘{old}’ 백업을 ‘{new}’ 백업으로 바꾸시겠습니까?"
|
||||
noBackups: "저장된 백업이 없습니다. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다."
|
||||
noBackups: '저장된 백업이 없습니다. "새 백업 만들기"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다.'
|
||||
createdAt: "만든 날짜: {date} {time}"
|
||||
updatedAt: "고친 날짜: {date} {time}"
|
||||
cannotLoad: "가져오기에 실패했습니다"
|
||||
|
|
@ -2413,6 +2414,20 @@ _2fa:
|
|||
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
|
||||
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
|
||||
moreDetailedGuideHere: "여기에 자세한 설명이 있습니다"
|
||||
_sso:
|
||||
connectedAccounts: "연동된 외부 계정"
|
||||
description: "외부 ID 공급자(OIDC) 계정을 이 계정에 연동하면 해당 계정으로도 로그인할 수 있게 됩니다."
|
||||
link: "외부 계정 연동"
|
||||
linkProvider: "{name}와(과) 연동"
|
||||
unlink: "연동 해제"
|
||||
unlinkConfirm: "이 외부 계정의 연동을 해제할까요? 해제하면 이 계정으로 로그인할 수 없게 됩니다."
|
||||
noLinkedAccounts: "연동된 외부 계정이 없습니다."
|
||||
lastUsedAt: "마지막 사용"
|
||||
linked: "외부 계정을 연동했습니다."
|
||||
unlinked: "연동을 해제했습니다."
|
||||
linkFailed: "외부 계정 연동에 실패했습니다."
|
||||
alreadyLinkedToOther: "이 외부 계정은 이미 다른 계정에 연동되어 있습니다."
|
||||
backToSecuritySettings: "보안 설정으로 돌아가기"
|
||||
_permissions:
|
||||
"read:account": "계정의 정보를 봅니다"
|
||||
"write:account": "계정의 정보를 변경합니다"
|
||||
|
|
@ -2868,7 +2883,7 @@ _deck:
|
|||
deleteProfile: "프로파일 삭제"
|
||||
introduction: "칼럼을 조합해서 나만의 인터페이스를 구성해 보아요!"
|
||||
introduction2: "나중에라도 화면 우측의 + 버튼을 눌러 새 칼럼을 추가할 수 있습니다."
|
||||
widgetsIntroduction: "칼럼 메뉴의 \"위젯 편집\"에서 위젯을 추가해 주세요"
|
||||
widgetsIntroduction: '칼럼 메뉴의 "위젯 편집"에서 위젯을 추가해 주세요'
|
||||
useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기"
|
||||
usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용됩니다"
|
||||
flexible: "폭 자동 조정"
|
||||
|
|
@ -3184,8 +3199,8 @@ _customEmojisManager:
|
|||
_register:
|
||||
uploadSettingTitle: "업로드 설정"
|
||||
uploadSettingDescription: "여기서 이모지를 업로드 할 때의 동작을 설정할 수 있습니다."
|
||||
directoryToCategoryLabel: "디렉토리 이름을 \"category\"로 입력하기"
|
||||
directoryToCategoryCaption: "디렉토리를 드래그 앤 드롭한 경우, 디렉토리 이름을 \"category\"로 입력합니다."
|
||||
directoryToCategoryLabel: '디렉토리 이름을 "category"로 입력하기'
|
||||
directoryToCategoryCaption: '디렉토리를 드래그 앤 드롭한 경우, 디렉토리 이름을 "category"로 입력합니다.'
|
||||
confirmRegisterEmojisDescription: "리스트에 표시되어진 이모지를 새로운 커스텀 이모지로 등록합니다. 실행할까요? (부하를 피하기 위해, 한 번에 등록할 수 있는 이모지는 {count}건까지 입니다.)"
|
||||
confirmClearEmojisDescription: "편집 내용을 지우고, 목록에 표시되어진 이모지를 지웁니다. 실행할까요?"
|
||||
confirmUploadEmojisDescription: "드래그 앤 드롭한 {count}개의 파일을 드라이브에 업로드 합니다. 실행할까요?"
|
||||
|
|
@ -3205,7 +3220,7 @@ _embedCodeGen:
|
|||
codeGeneratedDescription: "만들어진 코드를 웹 사이트에 붙여서 사용하세요."
|
||||
_selfXssPrevention:
|
||||
warning: "경고"
|
||||
title: "“이 화면에 뭔가를 붙여넣어라\"는 것은 모두 사기입니다."
|
||||
title: '“이 화면에 뭔가를 붙여넣어라"는 것은 모두 사기입니다.'
|
||||
description1: "여기에 무언가를 붙여넣으면 악의적인 유저에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
|
||||
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
|
||||
description3: "자세한 내용은 여기를 확인해 주세요. {link}"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.6.0",
|
||||
"version": "2026.6.0-beta.1-kyunet.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddUserSsoIdentity1782199402503 {
|
||||
name = 'AddUserSsoIdentity1782199402503'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "user_sso_identity" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastUsedAt" TIMESTAMP WITH TIME ZONE, "issuer" character varying(512) NOT NULL, "sub" character varying(512) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_c860890d77720817a8068ea7a24" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_9712cbad45d26350f12b14670d" ON "user_sso_identity" ("issuer") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f1ecf493e8a2bb4599dd77a16b" ON "user_sso_identity" ("userId") `);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6e108c01a468d54613cc02021d" ON "user_sso_identity" ("issuer", "sub") `);
|
||||
await queryRunner.query(`ALTER TABLE "user_sso_identity" ADD CONSTRAINT "FK_f1ecf493e8a2bb4599dd77a16b3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_sso_identity" DROP CONSTRAINT "FK_f1ecf493e8a2bb4599dd77a16b3"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_6e108c01a468d54613cc02021d"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_f1ecf493e8a2bb4599dd77a16b"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_9712cbad45d26350f12b14670d"`);
|
||||
await queryRunner.query(`DROP TABLE "user_sso_identity"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -120,6 +120,7 @@
|
|||
"node-html-parser": "7.1.0",
|
||||
"nodemailer": "8.0.10",
|
||||
"nsfwjs": "4.3.0",
|
||||
"openid-client": "6.8.4",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.5.1",
|
||||
"pg": "8.21.0",
|
||||
|
|
|
|||
357
packages/backend/scripts/measure-memory.mjs
Normal file
357
packages/backend/scripts/measure-memory.mjs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* This script starts the Misskey backend server, waits for it to be ready,
|
||||
* measures memory usage, and outputs the result as JSON.
|
||||
*
|
||||
* Usage: node scripts/measure-memory.mjs
|
||||
*/
|
||||
|
||||
import { fork } from 'node:child_process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 procStatusKeys = {
|
||||
VmPeak: 0,
|
||||
VmSize: 0,
|
||||
VmHWM: 0,
|
||||
VmRSS: 0,
|
||||
VmData: 0,
|
||||
VmStk: 0,
|
||||
VmExe: 0,
|
||||
VmLib: 0,
|
||||
VmPTE: 0,
|
||||
VmSwap: 0,
|
||||
};
|
||||
|
||||
const smapsRollupKeys = {
|
||||
Pss: 0,
|
||||
Shared_Clean: 0,
|
||||
Shared_Dirty: 0,
|
||||
Private_Clean: 0,
|
||||
Private_Dirty: 0,
|
||||
Swap: 0,
|
||||
SwapPss: 0,
|
||||
};
|
||||
|
||||
const runtimeKeys = {
|
||||
HeapTotal: 0,
|
||||
HeapUsed: 0,
|
||||
External: 0,
|
||||
ArrayBuffers: 0,
|
||||
};
|
||||
|
||||
const memoryKeys = {
|
||||
...procStatusKeys,
|
||||
...smapsRollupKeys,
|
||||
...runtimeKeys,
|
||||
};
|
||||
|
||||
const phases = ['beforeGc', 'afterGc', 'afterRequest'];
|
||||
|
||||
function parseMemoryFile(content, keys, path, required) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(keys)) {
|
||||
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) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
async function getMemoryUsage(pid) {
|
||||
const path = `/proc/${pid}/status`;
|
||||
const status = await fs.readFile(path, 'utf-8');
|
||||
|
||||
return parseMemoryFile(status, procStatusKeys, path, true);
|
||||
}
|
||||
|
||||
async function getSmapsRollupMemoryUsage(pid) {
|
||||
const path = `/proc/${pid}/smaps_rollup`;
|
||||
try {
|
||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'EACCES') {
|
||||
process.stderr.write(`Failed to read ${path}: ${err.message}\n`);
|
||||
return {};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
serverProcess.off('message', onMessage);
|
||||
reject(new Error(`Timed out waiting for ${description}`));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (message) => {
|
||||
if (!predicate(message)) return;
|
||||
globalThis.clearTimeout(timer);
|
||||
serverProcess.off('message', onMessage);
|
||||
resolve(message);
|
||||
};
|
||||
|
||||
serverProcess.on('message', onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async function getRuntimeMemoryUsage(serverProcess) {
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && message.type === 'memory usage',
|
||||
'memory usage',
|
||||
);
|
||||
|
||||
serverProcess.send('memory usage');
|
||||
|
||||
const message = await response;
|
||||
const memoryUsage = message.value;
|
||||
|
||||
return {
|
||||
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
|
||||
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
|
||||
External: bytesToKiB(memoryUsage.external),
|
||||
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAllMemoryUsage(serverProcess) {
|
||||
const pid = serverProcess.pid;
|
||||
return {
|
||||
...await getMemoryUsage(pid),
|
||||
...await getSmapsRollupMemoryUsage(pid),
|
||||
...await getRuntimeMemoryUsage(serverProcess),
|
||||
};
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
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);
|
||||
}
|
||||
|
||||
function summarizeResults(results) {
|
||||
const summary = {};
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {};
|
||||
for (const key of Object.keys(memoryKeys)) {
|
||||
const values = results
|
||||
.map(result => result[phase][key])
|
||||
.filter(value => Number.isFinite(value));
|
||||
|
||||
if (values.length > 0) {
|
||||
summary[phase][key] = median(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function measureMemory() {
|
||||
// Start the Misskey backend server using fork to enable IPC
|
||||
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
|
||||
cwd: join(__dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_ONLY_SERVER: '1',
|
||||
MK_NO_DAEMONS: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [...process.execArgv, '--expose-gc'],
|
||||
});
|
||||
|
||||
let serverReady = false;
|
||||
|
||||
// Listen for the 'ok' message from the server indicating it's ready
|
||||
serverProcess.on('message', (message) => {
|
||||
if (message === 'ok') {
|
||||
serverReady = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle server output
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
// Handle server error
|
||||
serverProcess.on('error', (err) => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
async function triggerGc() {
|
||||
const ok = waitForMessage(
|
||||
serverProcess,
|
||||
message => message === 'gc ok' || message === 'gc unavailable',
|
||||
'GC completion',
|
||||
);
|
||||
|
||||
serverProcess.send('gc');
|
||||
|
||||
const message = await ok;
|
||||
if (message === 'gc unavailable') {
|
||||
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
host: 'localhost',
|
||||
port: 61812,
|
||||
path: '/api/meta',
|
||||
method: 'POST',
|
||||
}, (res) => {
|
||||
res.on('data', () => { });
|
||||
res.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for server to be ready or timeout
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
throw new Error('Server startup timeout');
|
||||
}
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
const startupTime = Date.now() - startupStartTime;
|
||||
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
||||
|
||||
// Wait for memory to settle
|
||||
await setTimeout(MEMORY_SETTLE_TIME);
|
||||
|
||||
const beforeGc = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const afterGc = 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);
|
||||
|
||||
// Stop the server
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for process to exit
|
||||
let exited = false;
|
||||
await new Promise((resolve) => {
|
||||
serverProcess.on('exit', () => {
|
||||
exited = true;
|
||||
resolve(undefined);
|
||||
});
|
||||
// Force kill after 10 seconds if not exited
|
||||
setTimeout(10000).then(() => {
|
||||
if (!exited) {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
beforeGc,
|
||||
afterGc,
|
||||
afterRequest,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results = [];
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
|
||||
const res = await measureMemory();
|
||||
results.push(res);
|
||||
}
|
||||
|
||||
const summary = summarizeResults(results);
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
aggregation: 'median',
|
||||
measurement: {
|
||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||
memorySettleTimeMs: MEMORY_SETTLE_TIME,
|
||||
ipcTimeoutMs: IPC_TIMEOUT,
|
||||
requestCount: REQUEST_COUNT,
|
||||
},
|
||||
...summary,
|
||||
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);
|
||||
});
|
||||
|
|
@ -1,545 +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';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function readIntegerEnv(name, defaultValue, min) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function readBooleanEnv(name, defaultValue) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (rawValue === '1' || rawValue === 'true') return true;
|
||||
if (rawValue === '0' || rawValue === 'false') return false;
|
||||
throw new Error(`${name} must be one of: 1, 0, true, false`);
|
||||
}
|
||||
|
||||
const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure
|
||||
const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup
|
||||
const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle
|
||||
const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses
|
||||
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
|
||||
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
|
||||
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
|
||||
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
|
||||
|
||||
const heapSnapshotCategories = [
|
||||
'Code',
|
||||
'Strings',
|
||||
'JS arrays',
|
||||
'Typed arrays',
|
||||
'System objects',
|
||||
'Other JS objects',
|
||||
'Other non-JS objects',
|
||||
'Total',
|
||||
];
|
||||
|
||||
const typedArrayNames = new Set([
|
||||
'ArrayBuffer',
|
||||
'SharedArrayBuffer',
|
||||
'DataView',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float16Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'BigInt64Array',
|
||||
'BigUint64Array',
|
||||
'system / JSArrayBufferData',
|
||||
]);
|
||||
|
||||
const otherJsNodeTypes = new Set([
|
||||
'object',
|
||||
'closure',
|
||||
'regexp',
|
||||
'number',
|
||||
'symbol',
|
||||
'bigint',
|
||||
]);
|
||||
|
||||
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
for (const _key of keys) {
|
||||
const key = _key as KS[number];
|
||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
||||
if (match) {
|
||||
result[key] = parseInt(match[1], 10);
|
||||
} else if (required) {
|
||||
throw new Error(`Failed to parse ${key} from ${path}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytesToKiB(value: number) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
function createEmptyHeapSnapshotCategoryMap() {
|
||||
return Object.fromEntries(heapSnapshotCategories.map(category => [category, 0]));
|
||||
}
|
||||
|
||||
function isTypedArrayNode(type, name) {
|
||||
return typedArrayNames.has(name) ||
|
||||
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
|
||||
}
|
||||
|
||||
function isSystemNode(type, name) {
|
||||
return type === 'hidden' ||
|
||||
type === 'synthetic' ||
|
||||
type === 'object shape' ||
|
||||
name.startsWith('system /') ||
|
||||
name.startsWith('(system ');
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotNode(type, name) {
|
||||
if (type === 'code') return 'Code';
|
||||
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'Strings';
|
||||
if (isTypedArrayNode(type, name)) return 'Typed arrays';
|
||||
if (type === 'array' || (type === 'object' && name === 'Array')) return 'JS arrays';
|
||||
if (isSystemNode(type, name)) return 'System objects';
|
||||
if (otherJsNodeTypes.has(type)) return 'Other JS objects';
|
||||
return 'Other non-JS objects';
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
if (label.length <= 80) return label;
|
||||
return `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotBreakdown(category, type, name) {
|
||||
if (category === 'Strings') return type;
|
||||
|
||||
if (category === 'JS arrays') {
|
||||
if (type === 'array') return 'array nodes';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'Typed arrays') {
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
|
||||
if (typedArrayNames.has(name)) return name;
|
||||
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
|
||||
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'System objects') {
|
||||
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'Other JS objects') {
|
||||
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
|
||||
return type;
|
||||
}
|
||||
|
||||
if (category === 'Other non-JS objects') {
|
||||
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'Code') {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('bytecode')) return 'bytecode';
|
||||
if (lowerName.includes('builtin')) return 'builtins';
|
||||
if (lowerName.includes('regexp')) return 'regexp code';
|
||||
if (lowerName.includes('stub')) return 'stubs';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
|
||||
}
|
||||
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
function collapseHeapSnapshotBreakdown(breakdowns) {
|
||||
const collapsed = {};
|
||||
|
||||
for (const [category, children] of Object.entries(breakdowns)) {
|
||||
const entries = Object.entries(children)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||
const otherValue = entries
|
||||
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const categoryBreakdown = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) categoryBreakdown.Other = otherValue;
|
||||
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function analyzeHeapSnapshot(snapshot) {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const nodes = snapshot?.nodes;
|
||||
const strings = snapshot?.strings;
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required node fields');
|
||||
}
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
|
||||
const fieldCount = nodeFields.length;
|
||||
const categories = createEmptyHeapSnapshotCategoryMap();
|
||||
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
|
||||
const breakdowns = Object.fromEntries(
|
||||
heapSnapshotCategories
|
||||
.filter(category => category !== 'Total')
|
||||
.map(category => [category, {}]),
|
||||
);
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
|
||||
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||
const name = strings[nodes[offset + nameOffset]] ?? '';
|
||||
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
|
||||
const category = classifyHeapSnapshotNode(type, name);
|
||||
|
||||
categories[category] += selfSize;
|
||||
categories.Total += selfSize;
|
||||
nodeCounts[category]++;
|
||||
nodeCounts.Total++;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/status`;
|
||||
const status = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(status, procStatusKeys, path, true);
|
||||
}
|
||||
|
||||
async function getSmapsRollupMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/smaps_rollup`;
|
||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
||||
}
|
||||
|
||||
function waitForMessage(serverProcess, predicate, description, timeout = IPC_TIMEOUT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
serverProcess.off('message', onMessage);
|
||||
reject(new Error(`Timed out waiting for ${description}`));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (message) => {
|
||||
if (!predicate(message)) return;
|
||||
globalThis.clearTimeout(timer);
|
||||
serverProcess.off('message', onMessage);
|
||||
resolve(message);
|
||||
};
|
||||
|
||||
serverProcess.on('message', onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && message.type === 'memory usage',
|
||||
'memory usage',
|
||||
);
|
||||
|
||||
serverProcess.send('memory usage');
|
||||
|
||||
const message = await response;
|
||||
const memoryUsage = message.value;
|
||||
|
||||
return {
|
||||
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
|
||||
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
|
||||
External: bytesToKiB(memoryUsage.external),
|
||||
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
|
||||
};
|
||||
}
|
||||
|
||||
async function getHeapSnapshotStatistics(serverProcess: ChildProcess) {
|
||||
if (!HEAP_SNAPSHOT) return null;
|
||||
|
||||
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'),
|
||||
'heap snapshot',
|
||||
HEAP_SNAPSHOT_TIMEOUT,
|
||||
);
|
||||
|
||||
serverProcess.send({
|
||||
type: 'heap snapshot',
|
||||
path: snapshotPath,
|
||||
});
|
||||
|
||||
const message = await response;
|
||||
if (message.type === 'heap snapshot error') {
|
||||
throw new Error(`Failed to write heap snapshot: ${message.message}`);
|
||||
}
|
||||
|
||||
const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath;
|
||||
|
||||
try {
|
||||
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
|
||||
return analyzeHeapSnapshot(snapshot);
|
||||
} finally {
|
||||
await fs.unlink(writtenPath).catch(err => {
|
||||
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllMemoryUsage(serverProcess: ChildProcess) {
|
||||
const pid = serverProcess.pid!;
|
||||
return {
|
||||
...await getMemoryUsage(pid),
|
||||
...await getSmapsRollupMemoryUsage(pid),
|
||||
...await getRuntimeMemoryUsage(serverProcess),
|
||||
};
|
||||
}
|
||||
|
||||
async function measureMemory() {
|
||||
// Start the Misskey backend server using fork to enable IPC
|
||||
const serverProcess = fork(join(__dirname, '../built/entry.js'), [], {
|
||||
cwd: join(__dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_ONLY_SERVER: '1',
|
||||
MK_NO_DAEMONS: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: [...process.execArgv, '--expose-gc'],
|
||||
});
|
||||
|
||||
let serverReady = false;
|
||||
|
||||
// Listen for the 'ok' message from the server indicating it's ready
|
||||
serverProcess.on('message', (message) => {
|
||||
if (message === 'ok') {
|
||||
serverReady = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle server output
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
// Handle server error
|
||||
serverProcess.on('error', (err) => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
async function triggerGc() {
|
||||
const ok = waitForMessage(
|
||||
serverProcess,
|
||||
message => message === 'gc ok' || message === 'gc unavailable',
|
||||
'GC completion',
|
||||
);
|
||||
|
||||
serverProcess.send('gc');
|
||||
|
||||
const message = await ok;
|
||||
if (message === 'gc unavailable') {
|
||||
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
//function createRequest() {
|
||||
// return new Promise((resolve, reject) => {
|
||||
// const req = http.request({
|
||||
// host: 'localhost',
|
||||
// port: 61812,
|
||||
// path: '/api/meta',
|
||||
// method: 'POST',
|
||||
// }, (res) => {
|
||||
// res.on('data', () => { });
|
||||
// res.on('end', () => {
|
||||
// resolve();
|
||||
// });
|
||||
// });
|
||||
// req.on('error', (err) => {
|
||||
// reject(err);
|
||||
// });
|
||||
// req.end();
|
||||
// });
|
||||
//}
|
||||
|
||||
// Wait for server to be ready or timeout
|
||||
const startupStartTime = Date.now();
|
||||
while (!serverReady) {
|
||||
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
|
||||
serverProcess.kill('SIGTERM');
|
||||
throw new Error('Server startup timeout');
|
||||
}
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
const startupTime = Date.now() - startupStartTime;
|
||||
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
||||
|
||||
// Wait for memory to settle
|
||||
await setTimeout(MEMORY_SETTLE_TIME);
|
||||
|
||||
//const beforeGc = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
await triggerGc();
|
||||
|
||||
const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
//// create some http requests to simulate load
|
||||
//await Promise.all(
|
||||
// Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
|
||||
//);
|
||||
|
||||
//await triggerGc();
|
||||
|
||||
//const afterRequest = await getAllMemoryUsage(serverProcess);
|
||||
|
||||
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess);
|
||||
|
||||
// Stop the server
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for process to exit
|
||||
let exited = false;
|
||||
await new Promise((resolve) => {
|
||||
serverProcess.on('exit', () => {
|
||||
exited = true;
|
||||
resolve(undefined);
|
||||
});
|
||||
// Force kill after 10 seconds if not exited
|
||||
setTimeout(10000).then(() => {
|
||||
if (!exited) {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
phases: {
|
||||
//beforeGc,
|
||||
afterGc: {
|
||||
memoryUsage: memoryUsageAfterGC,
|
||||
heapSnapshot: heapSnapshotAfterGc,
|
||||
},
|
||||
//afterRequest,
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type MemoryReportRaw = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
measurement: {
|
||||
startupTimeoutMs: number;
|
||||
memorySettleTimeMs: number;
|
||||
ipcTimeoutMs: number;
|
||||
requestCount: number;
|
||||
heapSnapshot: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
breakdownTopN: number;
|
||||
};
|
||||
};
|
||||
samples: Awaited<ReturnType<typeof measureMemory>>[];
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const results = [];
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`);
|
||||
const res = await measureMemory();
|
||||
results.push(res);
|
||||
}
|
||||
|
||||
const result: MemoryReportRaw = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
measurement: {
|
||||
startupTimeoutMs: STARTUP_TIMEOUT,
|
||||
memorySettleTimeMs: MEMORY_SETTLE_TIME,
|
||||
ipcTimeoutMs: IPC_TIMEOUT,
|
||||
requestCount: REQUEST_COUNT,
|
||||
heapSnapshot: {
|
||||
enabled: HEAP_SNAPSHOT,
|
||||
timeoutMs: HEAP_SNAPSHOT_TIMEOUT,
|
||||
breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N,
|
||||
},
|
||||
},
|
||||
samples: results,
|
||||
};
|
||||
|
||||
// Output as JSON to stdout
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(JSON.stringify({
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
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';
|
||||
|
|
@ -107,21 +106,6 @@ process.on('message', msg => {
|
|||
value: process.memoryUsage(),
|
||||
});
|
||||
}
|
||||
} else if (msg != null && typeof msg === 'object' && 'type' in msg && msg.type === 'heap snapshot' && 'path' in msg && typeof msg.path === 'string') {
|
||||
if (process.send != null) {
|
||||
try {
|
||||
const path = writeHeapSnapshot(msg.path);
|
||||
process.send({
|
||||
type: 'heap snapshot',
|
||||
path,
|
||||
});
|
||||
} catch (err) {
|
||||
process.send({
|
||||
type: 'heap snapshot error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,30 @@ type Source = {
|
|||
enableQueryParamLogging?: boolean,
|
||||
}
|
||||
}
|
||||
|
||||
sso?: {
|
||||
oidc?: {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes?: string[];
|
||||
autoProvision?: boolean;
|
||||
usernameClaim?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SsoOidcConfig = {
|
||||
enabled: boolean;
|
||||
name: string | null;
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string[];
|
||||
autoProvision: boolean;
|
||||
usernameClaim: string;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
|
|
@ -212,6 +236,7 @@ export type Config = {
|
|||
perUserNotificationsMaxCount: number;
|
||||
deactivateAntennaThreshold: number;
|
||||
pidFile: string;
|
||||
ssoOidc: SsoOidcConfig | null;
|
||||
};
|
||||
|
||||
export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
|
||||
|
|
@ -340,6 +365,25 @@ export function loadConfig(): Config {
|
|||
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
|
||||
pidFile: config.pidFile,
|
||||
logging: config.logging,
|
||||
ssoOidc: normalizeSsoOidc(config.sso?.oidc),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSsoOidc(source: NonNullable<NonNullable<Source['sso']>['oidc']> | undefined): SsoOidcConfig | null {
|
||||
if (source == null) return null;
|
||||
if (!source.issuer || !source.clientId || !source.clientSecret) {
|
||||
throw new Error('sso.oidc requires issuer, clientId and clientSecret');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: source.enabled ?? true,
|
||||
name: source.name ?? null,
|
||||
issuer: source.issuer,
|
||||
clientId: source.clientId,
|
||||
clientSecret: source.clientSecret,
|
||||
scopes: source.scopes ?? ['openid', 'profile', 'email'],
|
||||
autoProvision: source.autoProvision ?? false,
|
||||
usernameClaim: source.usernameClaim ?? 'preferred_username',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { RelayService } from './RelayService.js';
|
|||
import { RoleService } from './RoleService.js';
|
||||
import { S3Service } from './S3Service.js';
|
||||
import { SignupService } from './SignupService.js';
|
||||
import { SsoOidcService } from './SsoOidcService.js';
|
||||
import { WebAuthnService } from './WebAuthnService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
import { CacheService } from './CacheService.js';
|
||||
|
|
@ -199,6 +200,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
|
|||
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
|
||||
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
||||
const $SsoOidcService: Provider = { provide: 'SsoOidcService', useExisting: SsoOidcService };
|
||||
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
|
||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
|
||||
|
|
@ -352,6 +354,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
RoleService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
SsoOidcService,
|
||||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
CacheService,
|
||||
|
|
@ -502,6 +505,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$RoleService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$SsoOidcService,
|
||||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
$CacheService,
|
||||
|
|
@ -652,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
RoleService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
SsoOidcService,
|
||||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
CacheService,
|
||||
|
|
@ -801,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$RoleService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$SsoOidcService,
|
||||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
$CacheService,
|
||||
|
|
|
|||
132
packages/backend/src/core/SsoOidcService.ts
Normal file
132
packages/backend/src/core/SsoOidcService.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as oidc from 'openid-client';
|
||||
import type { Config, SsoOidcConfig } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
// state (CSRF nonce + PKCE verifier) lifetime: 5min, single-use
|
||||
export const OIDC_STATE_TTL = 60 * 5;
|
||||
|
||||
export type OidcStateData = {
|
||||
verifier: string;
|
||||
nonce: string;
|
||||
/**
|
||||
* When set, the flow is a "link" flow: instead of signing in, the resolved
|
||||
* identity is attached to this already-authenticated local user.
|
||||
*/
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* OIDC issuer discovery / authorization-request building shared by the Fastify
|
||||
* SSO routes ({@link OidcClientService}) and the authenticated link API
|
||||
* endpoints. Lives in CoreModule so endpoints can inject it; the signin /
|
||||
* provisioning / handoff side effects stay in the Fastify adapter.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SsoOidcService {
|
||||
#oidcConfigPromise: Promise<oidc.Configuration> | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
) {
|
||||
}
|
||||
|
||||
private get oidcConfig(): SsoOidcConfig | null {
|
||||
const c = this.config.ssoOidc;
|
||||
return c != null && c.enabled ? c : null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isAvailable(): boolean {
|
||||
return this.oidcConfig != null;
|
||||
}
|
||||
|
||||
private get callbackUrl(): string {
|
||||
return `${this.config.url}/sso/oidc/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily run OIDC issuer discovery so that an unreachable IdP at boot does
|
||||
* not prevent the server from starting. The discovered Configuration is
|
||||
* memoized; on failure the promise is cleared so the next request retries.
|
||||
*/
|
||||
@bindThis
|
||||
public async getConfiguration(): Promise<oidc.Configuration> {
|
||||
const oidcConf = this.oidcConfig;
|
||||
if (oidcConf == null) {
|
||||
throw new Error('OIDC SSO is not configured');
|
||||
}
|
||||
|
||||
if (this.#oidcConfigPromise == null) {
|
||||
this.#oidcConfigPromise = oidc.discovery(
|
||||
new URL(oidcConf.issuer),
|
||||
oidcConf.clientId,
|
||||
oidcConf.clientSecret,
|
||||
).catch((err) => {
|
||||
this.#oidcConfigPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return this.#oidcConfigPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an authorization request URL, persisting the per-request PKCE
|
||||
* verifier + nonce (and, for link flows, the target userId) under a
|
||||
* single-use state key in Redis.
|
||||
*/
|
||||
@bindThis
|
||||
public async buildAuthorizationUrl(opts: { userId?: string }): Promise<string> {
|
||||
const oidcConf = this.oidcConfig;
|
||||
if (oidcConf == null) {
|
||||
throw new Error('OIDC SSO is not configured');
|
||||
}
|
||||
|
||||
const configuration = await this.getConfiguration();
|
||||
|
||||
const verifier = oidc.randomPKCECodeVerifier();
|
||||
const challenge = await oidc.calculatePKCECodeChallenge(verifier);
|
||||
const state = oidc.randomState();
|
||||
const nonce = oidc.randomNonce();
|
||||
|
||||
await this.redisClient.setex(
|
||||
`oidc:state:${state}`,
|
||||
OIDC_STATE_TTL,
|
||||
JSON.stringify({ verifier, nonce, userId: opts.userId } satisfies OidcStateData),
|
||||
);
|
||||
|
||||
const authorizationUrl = oidc.buildAuthorizationUrl(configuration, {
|
||||
redirect_uri: this.callbackUrl,
|
||||
scope: oidcConf.scopes.join(' '),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return authorizationUrl.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically read + delete the state, so a given state can only be
|
||||
* consumed once. Returns null when the state is unknown or expired.
|
||||
*/
|
||||
@bindThis
|
||||
public async consumeState(state: string): Promise<OidcStateData | null> {
|
||||
const raw = await this.redisClient.getdel(`oidc:state:${state}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as OidcStateData;
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +135,8 @@ export class MetaEntityService {
|
|||
noteSearchableScope: (this.config.fulltextSearch?.provider === 'meilisearch' && this.config.meilisearch?.scope === 'local') ? 'local' : 'global',
|
||||
maxFileSize: this.config.maxFileSize,
|
||||
federation: this.meta.federation,
|
||||
ssoOidcEnabled: this.config.ssoOidc?.enabled === true,
|
||||
ssoOidcName: this.config.ssoOidc?.name ?? null,
|
||||
};
|
||||
|
||||
return packed;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const DI = {
|
|||
userKeypairsRepository: Symbol('userKeypairsRepository'),
|
||||
userPendingsRepository: Symbol('userPendingsRepository'),
|
||||
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
|
||||
userSsoIdentitiesRepository: Symbol('userSsoIdentitiesRepository'),
|
||||
userPublickeysRepository: Symbol('userPublickeysRepository'),
|
||||
userListsRepository: Symbol('userListsRepository'),
|
||||
userListFavoritesRepository: Symbol('userListFavoritesRepository'),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import {
|
|||
MiUserProfile,
|
||||
MiUserPublickey,
|
||||
MiUserSecurityKey,
|
||||
MiUserSsoIdentity,
|
||||
MiWebhook,
|
||||
MiChatMessage,
|
||||
MiChatRoom,
|
||||
|
|
@ -190,6 +191,12 @@ const $userPublickeysRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userSsoIdentitiesRepository: Provider = {
|
||||
provide: DI.userSsoIdentitiesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiUserSsoIdentity).extend(miRepository as MiRepository<MiUserSsoIdentity>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userListsRepository: Provider = {
|
||||
provide: DI.userListsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiUserList).extend(miRepository as MiRepository<MiUserList>),
|
||||
|
|
@ -564,6 +571,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$userPendingsRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userSsoIdentitiesRepository,
|
||||
$userListsRepository,
|
||||
$userListFavoritesRepository,
|
||||
$userListMembershipsRepository,
|
||||
|
|
@ -642,6 +650,7 @@ const $reversiGamesRepository: Provider = {
|
|||
$userPendingsRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userSsoIdentitiesRepository,
|
||||
$userListsRepository,
|
||||
$userListFavoritesRepository,
|
||||
$userListMembershipsRepository,
|
||||
|
|
|
|||
52
packages/backend/src/models/UserSsoIdentity.ts
Normal file
52
packages/backend/src/models/UserSsoIdentity.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('user_sso_identity')
|
||||
@Index(['issuer', 'sub'], { unique: true })
|
||||
export class MiUserSsoIdentity {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone')
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public lastUsedAt: Date | null;
|
||||
|
||||
/**
|
||||
* The OIDC issuer this identity belongs to.
|
||||
*/
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
})
|
||||
public issuer: string;
|
||||
|
||||
/**
|
||||
* The stable subject identifier (`sub` claim) at the issuer.
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
})
|
||||
public sub: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(() => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ import { MiUserPending } from '@/models/UserPending.js';
|
|||
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||
import { MiUserSsoIdentity } from '@/models/UserSsoIdentity.js';
|
||||
import { MiWebhook } from '@/models/Webhook.js';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
|
|
@ -157,6 +158,7 @@ export {
|
|||
MiUserProfile,
|
||||
MiUserPublickey,
|
||||
MiUserSecurityKey,
|
||||
MiUserSsoIdentity,
|
||||
MiWebhook,
|
||||
MiSystemWebhook,
|
||||
MiChannel,
|
||||
|
|
@ -237,6 +239,7 @@ export type UserPendingsRepository = Repository<MiUserPending> & MiRepository<Mi
|
|||
export type UserProfilesRepository = Repository<MiUserProfile> & MiRepository<MiUserProfile>;
|
||||
export type UserPublickeysRepository = Repository<MiUserPublickey> & MiRepository<MiUserPublickey>;
|
||||
export type UserSecurityKeysRepository = Repository<MiUserSecurityKey> & MiRepository<MiUserSecurityKey>;
|
||||
export type UserSsoIdentitiesRepository = Repository<MiUserSsoIdentity> & MiRepository<MiUserSsoIdentity>;
|
||||
export type WebhooksRepository = Repository<MiWebhook> & MiRepository<MiWebhook>;
|
||||
export type SystemWebhooksRepository = Repository<MiSystemWebhook> & MiRepository<MiWebhook>;
|
||||
export type ChannelsRepository = Repository<MiChannel> & MiRepository<MiChannel>;
|
||||
|
|
|
|||
|
|
@ -309,6 +309,14 @@ export const packedMetaLiteSchema = {
|
|||
enum: ['all', 'specified', 'none'],
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ssoOidcEnabled: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ssoOidcName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import { MiUserPending } from '@/models/UserPending.js';
|
|||
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||
import { MiUserSsoIdentity } from '@/models/UserSsoIdentity.js';
|
||||
import { MiWebhook } from '@/models/Webhook.js';
|
||||
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
|
||||
import { MiChannel } from '@/models/Channel.js';
|
||||
|
|
@ -195,6 +196,7 @@ export const entities = [
|
|||
MiUserListMembership,
|
||||
MiUserNotePining,
|
||||
MiUserSecurityKey,
|
||||
MiUserSsoIdentity,
|
||||
MiUsedUsername,
|
||||
MiFollowing,
|
||||
MiFollowRequest,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { FeedService } from './web/FeedService.js';
|
|||
import { UrlPreviewService } from './web/UrlPreviewService.js';
|
||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
import { OidcClientService } from './sso/OidcClientService.js';
|
||||
|
||||
import MainStreamConnection from '@/server/api/stream/Connection.js';
|
||||
import { MainChannel } from './api/stream/channels/main.js';
|
||||
|
|
@ -102,6 +103,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
|||
NoteStreamingHidingService,
|
||||
OpenApiServerService,
|
||||
OAuth2ProviderService,
|
||||
OidcClientService,
|
||||
],
|
||||
exports: [
|
||||
ServerService,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,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 { OidcClientService } from './sso/OidcClientService.js';
|
||||
|
||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
private globalEventService: GlobalEventService,
|
||||
private loggerService: LoggerService,
|
||||
private oauth2ProviderService: OAuth2ProviderService,
|
||||
private oidcClientService: OidcClientService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('server', 'gray');
|
||||
}
|
||||
|
|
@ -148,6 +150,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
fastify.register(this.wellKnownServerService.createServer);
|
||||
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
|
||||
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
|
||||
fastify.register(this.oidcClientService.createServer, { prefix: '/sso/oidc' });
|
||||
fastify.register(this.healthServerService.createServer, { prefix: '/healthz' });
|
||||
|
||||
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
||||
|
|
|
|||
|
|
@ -33,8 +33,16 @@ export class SigninService {
|
|||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the side effects shared by every successful signin (history record,
|
||||
* login notification, new-login email) and return the user's native token.
|
||||
*
|
||||
* This is the redirect-flow-friendly core of {@link signin}: it does not
|
||||
* touch the reply, so callers that respond with a redirect (e.g. OIDC SSO)
|
||||
* can reuse it without being tied to the XHR/JSON response shape.
|
||||
*/
|
||||
@bindThis
|
||||
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
|
||||
public finalizeSignin(request: FastifyRequest, user: MiLocalUser): string {
|
||||
setImmediate(async () => {
|
||||
this.notificationService.createNotification(user.id, 'login', {});
|
||||
|
||||
|
|
@ -56,11 +64,18 @@ export class SigninService {
|
|||
}
|
||||
});
|
||||
|
||||
return user.token!;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
|
||||
const token = this.finalizeSignin(request, user);
|
||||
|
||||
reply.code(200);
|
||||
return {
|
||||
finished: true,
|
||||
id: user.id,
|
||||
i: user.token!,
|
||||
i: token,
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,9 @@ export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-
|
|||
export * as 'i/registry/set' from './endpoints/i/registry/set.js';
|
||||
export * as 'i/revoke-token' from './endpoints/i/revoke-token.js';
|
||||
export * as 'i/signin-history' from './endpoints/i/signin-history.js';
|
||||
export * as 'i/sso/oidc/generate-link-url' from './endpoints/i/sso/oidc/generate-link-url.js';
|
||||
export * as 'i/sso/oidc/list' from './endpoints/i/sso/oidc/list.js';
|
||||
export * as 'i/sso/oidc/unlink' from './endpoints/i/sso/oidc/unlink.js';
|
||||
export * as 'i/unpin' from './endpoints/i/unpin.js';
|
||||
export * as 'i/update' from './endpoints/i/update.js';
|
||||
export * as 'i/update-email' from './endpoints/i/update-email.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserProfilesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||
import { SsoOidcService } from '@/core/SsoOidcService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '823ed611-2311-451d-9337-90752b85ae59',
|
||||
},
|
||||
unavailable: {
|
||||
message: 'OIDC SSO is not available on this server.',
|
||||
code: 'SSO_OIDC_UNAVAILABLE',
|
||||
id: '0421fab7-215f-4444-b2a8-ec92898bc922',
|
||||
},
|
||||
unreachable: {
|
||||
message: 'Failed to reach the identity provider.',
|
||||
code: 'SSO_OIDC_UNREACHABLE',
|
||||
id: '94c2a42c-bade-4f7e-8896-08ca73751e5c',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
password: { type: 'string' },
|
||||
token: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['password'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userAuthService: UserAuthService,
|
||||
private ssoOidcService: SsoOidcService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (!this.ssoOidcService.isAvailable()) {
|
||||
throw new ApiError(meta.errors.unavailable);
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
||||
if (profile.twoFactorEnabled) {
|
||||
if (ps.token == null) {
|
||||
throw new Error('authentication failed');
|
||||
}
|
||||
try {
|
||||
await this.userAuthService.twoFactorAuthenticate(profile, ps.token);
|
||||
} catch (_) {
|
||||
throw new Error('authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
if (!passwordMatched) {
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.ssoOidcService.buildAuthorizationUrl({ userId: me.id });
|
||||
} catch {
|
||||
throw new ApiError(meta.errors.unreachable);
|
||||
}
|
||||
|
||||
return { url };
|
||||
});
|
||||
}
|
||||
}
|
||||
77
packages/backend/src/server/api/endpoints/i/sso/oidc/list.ts
Normal file
77
packages/backend/src/server/api/endpoints/i/sso/oidc/list.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserSsoIdentitiesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'misskey:id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
lastUsedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
issuer: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
sub: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userSsoIdentitiesRepository)
|
||||
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const identities = await this.userSsoIdentitiesRepository.findBy({ userId: me.id });
|
||||
|
||||
return identities
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map(identity => ({
|
||||
id: identity.id,
|
||||
createdAt: identity.createdAt.toISOString(),
|
||||
lastUsedAt: identity.lastUsedAt ? identity.lastUsedAt.toISOString() : null,
|
||||
issuer: identity.issuer,
|
||||
sub: identity.sub,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserProfilesRepository, UserSsoIdentitiesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
incorrectPassword: {
|
||||
message: 'Incorrect password.',
|
||||
code: 'INCORRECT_PASSWORD',
|
||||
id: '9f90b4a9-af39-4d53-9b37-a967d2a91ec6',
|
||||
},
|
||||
noSuchIdentity: {
|
||||
message: 'No such SSO identity.',
|
||||
code: 'NO_SUCH_SSO_IDENTITY',
|
||||
id: '39e92893-8944-4dfa-9ac9-865004def6ba',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
identityId: { type: 'string', format: 'misskey:id' },
|
||||
password: { type: 'string' },
|
||||
token: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['identityId', 'password'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSsoIdentitiesRepository)
|
||||
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
|
||||
|
||||
private userAuthService: UserAuthService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||
|
||||
if (profile.twoFactorEnabled) {
|
||||
if (ps.token == null) {
|
||||
throw new Error('authentication failed');
|
||||
}
|
||||
try {
|
||||
await this.userAuthService.twoFactorAuthenticate(profile, ps.token);
|
||||
} catch (_) {
|
||||
throw new Error('authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
|
||||
if (!passwordMatched) {
|
||||
throw new ApiError(meta.errors.incorrectPassword);
|
||||
}
|
||||
|
||||
const identity = await this.userSsoIdentitiesRepository.findOneBy({ id: ps.identityId, userId: me.id });
|
||||
if (identity == null) {
|
||||
throw new ApiError(meta.errors.noSuchIdentity);
|
||||
}
|
||||
|
||||
await this.userSsoIdentitiesRepository.delete({ id: identity.id });
|
||||
});
|
||||
}
|
||||
}
|
||||
301
packages/backend/src/server/sso/OidcClientService.ts
Normal file
301
packages/backend/src/server/sso/OidcClientService.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { IsNull } from 'typeorm';
|
||||
import * as oidc from 'openid-client';
|
||||
import type { Config, SsoOidcConfig } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { UsersRepository, UserSsoIdentitiesRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { SignupService } from '@/core/SignupService.js';
|
||||
import { SsoOidcService } from '@/core/SsoOidcService.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { SigninService } from '@/server/api/SigninService.js';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
|
||||
// one-time token handoff code lifetime: 2min, single-use
|
||||
const HANDOFF_TTL = 60 * 2;
|
||||
|
||||
@Injectable()
|
||||
export class OidcClientService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userSsoIdentitiesRepository)
|
||||
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private signupService: SignupService,
|
||||
private signinService: SigninService,
|
||||
private ssoOidcService: SsoOidcService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('oidc-client');
|
||||
}
|
||||
|
||||
private get oidcConfig(): SsoOidcConfig | null {
|
||||
const c = this.config.ssoOidc;
|
||||
return c != null && c.enabled ? c : null;
|
||||
}
|
||||
|
||||
private replyError(reply: FastifyReply, status: number, message: string): void {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.code(status);
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
reply.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce the browser back to the in-app redirect page with the outcome of a
|
||||
* link flow. The user is already signed in, so there is no token handoff.
|
||||
*/
|
||||
private redirectLinkResult(reply: FastifyReply, result: 'success' | 'error', reason?: string): FastifyReply {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
const params = new URLSearchParams({ link: result });
|
||||
if (reason != null) params.set('reason', reason);
|
||||
return reply.redirect(`/sso/oidc/redirect?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a valid, unused local username from an OIDC claim for
|
||||
* auto-provisioning. Local usernames must match /^\w{1,20}$/.
|
||||
*/
|
||||
private async resolveProvisionUsername(claimValue: unknown): Promise<string> {
|
||||
const base = (typeof claimValue === 'string' ? claimValue : '')
|
||||
.replace(/[^\w]/g, '_')
|
||||
.slice(0, 20)
|
||||
.replace(/^_+|_+$/g, '');
|
||||
const seed = base.length > 0 ? base : 'user';
|
||||
|
||||
// Try the sanitized value first, then fall back to suffixed variants.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = i === 0
|
||||
? seed.slice(0, 20)
|
||||
: `${seed.slice(0, 20 - 5)}_${secureRndstr(4, { chars: '0123456789abcdefghijklmnopqrstuvwxyz' })}`;
|
||||
const exists = await this.usersRepository.exists({
|
||||
where: { usernameLower: candidate.toLowerCase(), host: IsNull() },
|
||||
});
|
||||
if (!exists) return candidate;
|
||||
}
|
||||
|
||||
throw new Error('Could not allocate a unique username for auto-provisioning');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/login', async (_request, reply) => {
|
||||
const oidcConf = this.oidcConfig;
|
||||
if (oidcConf == null) {
|
||||
return this.replyError(reply, 404, 'OIDC SSO is not enabled.');
|
||||
}
|
||||
|
||||
let authorizationUrl: string;
|
||||
try {
|
||||
authorizationUrl = await this.ssoOidcService.buildAuthorizationUrl({});
|
||||
} catch (err) {
|
||||
this.logger.error('OIDC issuer discovery failed', { err });
|
||||
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
return reply.redirect(authorizationUrl);
|
||||
});
|
||||
|
||||
fastify.get('/callback', async (request, reply) => {
|
||||
const oidcConf = this.oidcConfig;
|
||||
if (oidcConf == null) {
|
||||
return this.replyError(reply, 404, 'OIDC SSO is not enabled.');
|
||||
}
|
||||
|
||||
const query = request.query as Record<string, string | undefined>;
|
||||
const state = query.state;
|
||||
if (!state) {
|
||||
return this.replyError(reply, 400, 'Missing state parameter.');
|
||||
}
|
||||
|
||||
// consumeState = atomic read + delete, so a state can only be consumed once.
|
||||
const stateData = await this.ssoOidcService.consumeState(state);
|
||||
if (stateData == null) {
|
||||
return this.replyError(reply, 403, 'Invalid or expired login session. Please try again.');
|
||||
}
|
||||
const { verifier, nonce, userId } = stateData;
|
||||
|
||||
let configuration: oidc.Configuration;
|
||||
try {
|
||||
configuration = await this.ssoOidcService.getConfiguration();
|
||||
} catch (err) {
|
||||
this.logger.error('OIDC issuer discovery failed', { err });
|
||||
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
|
||||
}
|
||||
|
||||
let claims: oidc.IDToken | undefined;
|
||||
let issuer: string;
|
||||
let sub: string;
|
||||
try {
|
||||
const currentUrl = new URL(request.url, this.config.url);
|
||||
const tokens = await oidc.authorizationCodeGrant(configuration, currentUrl, {
|
||||
pkceCodeVerifier: verifier,
|
||||
expectedNonce: nonce,
|
||||
expectedState: state,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
claims = tokens.claims();
|
||||
if (claims == null) {
|
||||
throw new Error('id_token has no claims');
|
||||
}
|
||||
issuer = claims.iss;
|
||||
sub = claims.sub;
|
||||
} catch (err) {
|
||||
this.logger.warn('OIDC code exchange / id_token validation failed', { err });
|
||||
if (userId != null) {
|
||||
return this.redirectLinkResult(reply, 'error', 'auth_failed');
|
||||
}
|
||||
return this.replyError(reply, 403, 'Authentication with the identity provider failed.');
|
||||
}
|
||||
|
||||
// Link flow: attach this identity to the already-authenticated user
|
||||
// rather than signing in / auto-provisioning.
|
||||
if (userId != null) {
|
||||
return this.handleLink(reply, issuer, sub, userId);
|
||||
}
|
||||
|
||||
let identity = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
|
||||
let user: MiLocalUser | null = null;
|
||||
|
||||
if (identity != null) {
|
||||
user = await this.usersRepository.findOneBy({
|
||||
id: identity.userId,
|
||||
host: IsNull(),
|
||||
}) as MiLocalUser | null;
|
||||
if (user == null) {
|
||||
this.logger.warn(`Dangling SSO identity for issuer=${issuer} sub=${sub}; user missing`);
|
||||
return this.replyError(reply, 403, 'The linked account no longer exists.');
|
||||
}
|
||||
} else {
|
||||
if (!oidcConf.autoProvision) {
|
||||
return this.replyError(reply, 403, 'No Misskey account is linked to this identity, and auto-provisioning is disabled.');
|
||||
}
|
||||
|
||||
let username: string;
|
||||
try {
|
||||
username = await this.resolveProvisionUsername(claims[oidcConf.usernameClaim]);
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to resolve username for auto-provisioning', { err });
|
||||
return this.replyError(reply, 500, 'Could not create an account automatically.');
|
||||
}
|
||||
|
||||
try {
|
||||
const { account } = await this.signupService.signup({ username, password: null });
|
||||
user = account as MiLocalUser;
|
||||
} catch (err) {
|
||||
this.logger.error('Auto-provisioning signup failed', { err });
|
||||
return this.replyError(reply, 500, 'Could not create an account automatically.');
|
||||
}
|
||||
|
||||
identity = await this.userSsoIdentitiesRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
createdAt: new Date(),
|
||||
lastUsedAt: null,
|
||||
issuer,
|
||||
sub,
|
||||
userId: user.id,
|
||||
});
|
||||
this.logger.info(`Auto-provisioned user ${user.id} (@${username}) for issuer=${issuer} sub=${sub}`);
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return this.replyError(reply, 403, 'This account has been suspended.');
|
||||
}
|
||||
|
||||
await this.userSsoIdentitiesRepository.update({ id: identity.id }, { lastUsedAt: new Date() });
|
||||
|
||||
const token = this.signinService.finalizeSignin(request, user);
|
||||
|
||||
// One-time handoff code: the SPA exchanges it for the native token,
|
||||
// avoiding leaking the token via the redirect URL itself.
|
||||
const handoffCode = secureRndstr(32);
|
||||
await this.redisClient.setex(`oidc:handoff:${handoffCode}`, HANDOFF_TTL, token);
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
return reply.redirect(`/sso/oidc/redirect?session=${handoffCode}`);
|
||||
});
|
||||
|
||||
fastify.post<{ Body: { session?: string } }>('/exchange', async (request, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
const session = request.body?.session;
|
||||
if (!session || typeof session !== 'string') {
|
||||
reply.code(400);
|
||||
return { error: 'Missing session' };
|
||||
}
|
||||
|
||||
const token = await this.redisClient.getdel(`oidc:handoff:${session}`);
|
||||
if (!token) {
|
||||
reply.code(401);
|
||||
return { error: 'Invalid or expired session' };
|
||||
}
|
||||
|
||||
reply.code(200);
|
||||
return { token };
|
||||
});
|
||||
|
||||
// NOTE: intentionally no catch-all here. Unmatched paths under this
|
||||
// prefix (e.g. `/sso/oidc/redirect`) must fall through to the SPA
|
||||
// handler so the frontend redirect page can render.
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a freshly authenticated OIDC identity to an existing local user.
|
||||
* Idempotent when the identity is already linked to that same user; refuses
|
||||
* when it belongs to someone else.
|
||||
*/
|
||||
@bindThis
|
||||
private async handleLink(reply: FastifyReply, issuer: string, sub: string, userId: string): Promise<FastifyReply> {
|
||||
const existing = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
|
||||
if (existing != null) {
|
||||
if (existing.userId === userId) {
|
||||
await this.userSsoIdentitiesRepository.update({ id: existing.id }, { lastUsedAt: new Date() });
|
||||
return this.redirectLinkResult(reply, 'success');
|
||||
}
|
||||
// The identity is already bound to a different account.
|
||||
return this.redirectLinkResult(reply, 'error', 'already_linked');
|
||||
}
|
||||
|
||||
const target = await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null;
|
||||
if (target == null) {
|
||||
this.logger.warn(`Link flow target user ${userId} missing for issuer=${issuer} sub=${sub}`);
|
||||
return this.redirectLinkResult(reply, 'error', 'no_user');
|
||||
}
|
||||
if (target.isSuspended) {
|
||||
return this.redirectLinkResult(reply, 'error', 'suspended');
|
||||
}
|
||||
|
||||
await this.userSsoIdentitiesRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
createdAt: new Date(),
|
||||
lastUsedAt: new Date(),
|
||||
issuer,
|
||||
sub,
|
||||
userId,
|
||||
});
|
||||
this.logger.info(`Linked SSO identity issuer=${issuer} sub=${sub} to user ${userId}`);
|
||||
return this.redirectLinkResult(reply, 'success');
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</MkReactionsViewer>
|
||||
<footer :class="$style.footer">
|
||||
<button :class="$style.footerButton" class="_button" :aria-label="i18n.ts.details" @click="openDetail()">
|
||||
<i class="ti ti-info-circle"></i>
|
||||
</button>
|
||||
<button :class="$style.footerButton" class="_button" @click="reply()">
|
||||
<i class="ti ti-arrow-back-up"></i>
|
||||
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||
|
|
@ -154,6 +157,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button v-if="remoteUrl" :class="$style.footerButton" class="_button" :aria-label="i18n.ts.showOnRemote" @click="showOnRemote()">
|
||||
<i class="ti ti-external-link"></i>
|
||||
</button>
|
||||
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
|
|
@ -290,6 +296,7 @@ if (noteViewInterruptors.length > 0) {
|
|||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note) ?? note;
|
||||
const remoteUrl = appearNote.url ?? appearNote.uri ?? null;
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
|
|
@ -483,6 +490,16 @@ async function renote() {
|
|||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function openDetail(): void {
|
||||
if (props.mock) return;
|
||||
os.pageWindow(`/notes/${appearNote.id}`);
|
||||
}
|
||||
|
||||
function showOnRemote(): void {
|
||||
if (remoteUrl == null) return;
|
||||
window.open(remoteUrl, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button v-if="remoteUrl" class="_button" :class="$style.noteFooterButton" :aria-label="i18n.ts.showOnRemote" @click="showOnRemote()">
|
||||
<i class="ti ti-external-link"></i>
|
||||
</button>
|
||||
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
|
|
@ -320,6 +323,7 @@ if (noteViewInterruptors.length > 0) {
|
|||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note) ?? note;
|
||||
const remoteUrl = appearNote.url ?? appearNote.uri ?? null;
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
|
|
@ -470,6 +474,11 @@ async function renote() {
|
|||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function showOnRemote(): void {
|
||||
if (remoteUrl == null) return;
|
||||
window.open(remoteUrl, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }}
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
<!-- 外部IdP(OIDC)ログイン -->
|
||||
<template v-if="instance.ssoOidcEnabled">
|
||||
<div :class="$style.orHr">
|
||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<MkButton type="button" style="margin: auto auto;" large rounded @click="onSsoOidcClick">
|
||||
<i class="ti ti-login" style="font-size: medium;"></i>{{ instance.ssoOidcName ? i18n.tsx.signinWith({ x: instance.ssoOidcName }) : i18n.ts.signinWithSso }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -60,6 +72,7 @@ import { query, extractDomain } from '@@/js/url.js';
|
|||
import { host as configHost } from '@@/js/config.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
|
@ -85,6 +98,11 @@ const host = toUnicode(configHost);
|
|||
|
||||
const username = ref(props.initialUsername ?? '');
|
||||
|
||||
function onSsoOidcClick(): void {
|
||||
// Full-page redirect into the OIDC authorization flow.
|
||||
window.location.href = '/sso/oidc/login';
|
||||
}
|
||||
|
||||
//#region Open on remote
|
||||
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
|
||||
switch (options.type) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']">
|
||||
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa', 'sso']">
|
||||
<div class="_gaps_m">
|
||||
<MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00">
|
||||
<SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText>
|
||||
|
|
@ -24,6 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<X2fa/>
|
||||
|
||||
<XSso/>
|
||||
|
||||
<SearchMarker :keywords="['signin', 'login', 'history', 'log']">
|
||||
<FormSection>
|
||||
<template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template>
|
||||
|
|
@ -59,6 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed, markRaw } from 'vue';
|
||||
import X2fa from './2fa.vue';
|
||||
import XSso from './sso.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
|
|
|||
151
packages/frontend/src/pages/settings/sso.vue
Normal file
151
packages/frontend/src/pages/settings/sso.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<SearchMarker v-if="instance.ssoOidcEnabled" markerId="sso" :keywords="['sso', 'oidc', 'oauth', 'login']">
|
||||
<FormSection :first="first">
|
||||
<template #label><SearchLabel>{{ i18n.ts._sso.connectedAccounts }}</SearchLabel></template>
|
||||
<template #caption><SearchText>{{ i18n.ts._sso.description }}</SearchText></template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<template v-else>
|
||||
<MkInfo v-if="identities.length === 0">{{ i18n.ts._sso.noLinkedAccounts }}</MkInfo>
|
||||
<div v-else class="_gaps_s">
|
||||
<div v-for="identity in identities" :key="identity.id" v-panel :class="$style.item">
|
||||
<div :class="$style.itemBody">
|
||||
<div :class="$style.itemName">{{ providerName }}</div>
|
||||
<div :class="$style.itemSub">{{ identity.sub }}</div>
|
||||
<div v-if="identity.lastUsedAt" :class="$style.itemMeta">
|
||||
{{ i18n.ts._sso.lastUsedAt }}: <MkTime :time="identity.lastUsedAt"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkButton danger @click="unlink(identity)">{{ i18n.ts._sso.unlink }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkButton primary @click="link">
|
||||
<i class="ti ti-link"></i> {{ providerName ? i18n.tsx._sso.linkProvider({ name: providerName }) : i18n.ts._sso.link }}
|
||||
</MkButton>
|
||||
</template>
|
||||
</div>
|
||||
</FormSection>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
type SsoIdentity = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
issuer: string;
|
||||
sub: string;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<{
|
||||
first?: boolean;
|
||||
}>(), {
|
||||
first: false,
|
||||
});
|
||||
|
||||
const identities = ref<SsoIdentity[]>([]);
|
||||
const fetching = ref(true);
|
||||
|
||||
const providerName = computed(() => instance.ssoOidcName ?? '');
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (!instance.ssoOidcEnabled) return;
|
||||
fetching.value = true;
|
||||
try {
|
||||
identities.value = await misskeyApi('i/sso/oidc/list', {});
|
||||
} finally {
|
||||
fetching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function link(): Promise<void> {
|
||||
const auth = await os.authenticateDialog();
|
||||
if (auth.canceled) return;
|
||||
|
||||
try {
|
||||
const res = await os.apiWithDialog('i/sso/oidc/generate-link-url', {
|
||||
password: auth.result.password,
|
||||
token: auth.result.token,
|
||||
});
|
||||
// Hand the browser over to the identity provider; the callback returns to
|
||||
// /sso/oidc/redirect, which reports the result.
|
||||
window.location.href = res.url;
|
||||
} catch {
|
||||
// apiWithDialog already surfaced the error.
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink(identity: SsoIdentity): Promise<void> {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts._sso.unlinkConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const auth = await os.authenticateDialog();
|
||||
if (auth.canceled) return;
|
||||
|
||||
try {
|
||||
await os.apiWithDialog('i/sso/oidc/unlink', {
|
||||
identityId: identity.id,
|
||||
password: auth.result.password,
|
||||
token: auth.result.token,
|
||||
});
|
||||
os.toast(i18n.ts._sso.unlinked);
|
||||
await refresh();
|
||||
} catch {
|
||||
// apiWithDialog already surfaced the error.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--MI-radius);
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.itemSub {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
128
packages/frontend/src/pages/sso-oidc-redirect.vue
Normal file
128
packages/frontend/src/pages/sso-oidc-redirect.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkLoading v-if="state === 'loading'"/>
|
||||
<div v-else-if="state === 'linked'" class="_gaps_m" :class="$style.message">
|
||||
<i class="ti ti-circle-check" :class="$style.successIcon"></i>
|
||||
<div>{{ i18n.ts._sso.linked }}</div>
|
||||
<MkButton primary rounded style="margin: 0 auto;" @click="backToSettings">{{ i18n.ts._sso.backToSecuritySettings }}</MkButton>
|
||||
</div>
|
||||
<div v-else-if="state === 'linkError'" class="_gaps_m" :class="$style.message">
|
||||
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
|
||||
<div>{{ linkErrorText }}</div>
|
||||
<MkButton primary rounded style="margin: 0 auto;" @click="backToSettings">{{ i18n.ts._sso.backToSecuritySettings }}</MkButton>
|
||||
</div>
|
||||
<div v-else-if="state === 'error'" class="_gaps_m" :class="$style.message">
|
||||
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
|
||||
<div>{{ i18n.ts.signinFailed }}</div>
|
||||
<MkButton primary rounded style="margin: 0 auto;" @click="retry">{{ i18n.ts.retry }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { login } from '@/accounts.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { definePage } from '@/page.js';
|
||||
|
||||
const props = defineProps<{
|
||||
session?: string;
|
||||
link?: string;
|
||||
reason?: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const state = ref<'loading' | 'error' | 'linked' | 'linkError'>('loading');
|
||||
|
||||
const linkErrorText = computed(() => {
|
||||
switch (props.reason) {
|
||||
case 'already_linked': return i18n.ts._sso.alreadyLinkedToOther;
|
||||
default: return i18n.ts._sso.linkFailed;
|
||||
}
|
||||
});
|
||||
|
||||
async function exchange(): Promise<void> {
|
||||
state.value = 'loading';
|
||||
|
||||
if (!props.session) {
|
||||
state.value = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await window.fetch('/sso/oidc/exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: props.session }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
state.value = 'error';
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { token?: string };
|
||||
if (!body.token) {
|
||||
state.value = 'error';
|
||||
return;
|
||||
}
|
||||
// Persists the account and reloads into the app.
|
||||
await login(body.token, '/');
|
||||
} catch {
|
||||
state.value = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function retry(): void {
|
||||
// Restart the whole OIDC flow; the one-time session code is already consumed.
|
||||
window.location.href = '/sso/oidc/login';
|
||||
}
|
||||
|
||||
function backToSettings(): void {
|
||||
router.push('/settings/security');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The link flow lands here with a `link` result instead of a session code.
|
||||
if (props.link != null) {
|
||||
state.value = props.link === 'success' ? 'linked' : 'linkError';
|
||||
return;
|
||||
}
|
||||
exchange();
|
||||
});
|
||||
|
||||
definePage(() => ({
|
||||
title: 'SSO',
|
||||
icon: 'ti ti-login',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
font-size: 2.5em;
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
font-size: 2.5em;
|
||||
color: var(--MI_THEME-success);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -297,6 +297,14 @@ export const ROUTE_DEF = [{
|
|||
}, {
|
||||
path: '/oauth/authorize',
|
||||
component: page(() => import('@/pages/oauth.vue')),
|
||||
}, {
|
||||
path: '/sso/oidc/redirect',
|
||||
component: page(() => import('@/pages/sso-oidc-redirect.vue')),
|
||||
query: {
|
||||
session: 'session',
|
||||
link: 'link',
|
||||
reason: 'reason',
|
||||
},
|
||||
}, {
|
||||
path: '/tags/:tag',
|
||||
component: page(() => import('@/pages/tag.vue')),
|
||||
|
|
|
|||
|
|
@ -297,10 +297,6 @@ export function getNoteMenu(props: {
|
|||
});
|
||||
}
|
||||
|
||||
function openDetail(): void {
|
||||
os.pageWindow(`/notes/${appearNote.id}`);
|
||||
}
|
||||
|
||||
async function translate(): Promise<void> {
|
||||
if (props.translation.value != null) return;
|
||||
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
|
||||
|
|
@ -364,10 +360,6 @@ export function getNoteMenu(props: {
|
|||
}
|
||||
|
||||
menuItems.push({
|
||||
icon: 'ti ti-info-circle',
|
||||
text: i18n.ts.details,
|
||||
action: openDetail,
|
||||
}, {
|
||||
icon: 'ti ti-copy',
|
||||
text: i18n.ts.copyContent,
|
||||
action: copyContent,
|
||||
|
|
@ -380,12 +372,6 @@ export function getNoteMenu(props: {
|
|||
action: () => {
|
||||
copyToClipboard(link);
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-external-link',
|
||||
text: i18n.ts.showOnRemote,
|
||||
action: () => {
|
||||
window.open(link, '_blank', 'noopener');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const embedMenu = getNoteEmbedCodeMenu(appearNote, i18n.ts.embed);
|
||||
|
|
@ -541,10 +527,6 @@ export function getNoteMenu(props: {
|
|||
}
|
||||
} else {
|
||||
menuItems.push({
|
||||
icon: 'ti ti-info-circle',
|
||||
text: i18n.ts.details,
|
||||
action: openDetail,
|
||||
}, {
|
||||
icon: 'ti ti-copy',
|
||||
text: i18n.ts.copyContent,
|
||||
action: copyContent,
|
||||
|
|
@ -557,12 +539,6 @@ export function getNoteMenu(props: {
|
|||
action: () => {
|
||||
copyToClipboard(link);
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-external-link',
|
||||
text: i18n.ts.showOnRemote,
|
||||
action: () => {
|
||||
window.open(link, '_blank', 'noopener');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const embedMenu = getNoteEmbedCodeMenu(appearNote, i18n.ts.embed);
|
||||
|
|
|
|||
|
|
@ -27,29 +27,27 @@ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.s
|
|||
function getBundleVisualizerPlugin(): PluginOption[] {
|
||||
if (process.env.FRONTEND_BUNDLE_VISUALIZER !== 'true') return [];
|
||||
|
||||
const visualizerOptions = {
|
||||
title: 'Misskey frontend bundle visualizer',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
projectRoot: path.resolve(__dirname, '../..'),
|
||||
};
|
||||
const plugins = [
|
||||
const template = process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'markdown'
|
||||
? 'markdown'
|
||||
: process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'raw-data'
|
||||
? 'raw-data'
|
||||
: 'treemap';
|
||||
const defaultFilename = template === 'markdown'
|
||||
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/report.md')
|
||||
: template === 'raw-data'
|
||||
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.json')
|
||||
: path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.html');
|
||||
|
||||
return [
|
||||
visualizer({
|
||||
...visualizerOptions,
|
||||
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE,
|
||||
template: 'raw-data',
|
||||
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE ?? defaultFilename,
|
||||
title: 'Misskey frontend bundle visualizer',
|
||||
template,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
projectRoot: path.resolve(__dirname, '../..'),
|
||||
}) as PluginOption,
|
||||
];
|
||||
|
||||
if (process.env.FRONTEND_BUNDLE_VISUALIZER_HTML_FILE != null) {
|
||||
plugins.push(visualizer({
|
||||
...visualizerOptions,
|
||||
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_HTML_FILE,
|
||||
template: 'treemap',
|
||||
}) as PluginOption);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5264,6 +5264,10 @@ export interface Locale extends ILocale {
|
|||
* パスキーでログイン
|
||||
*/
|
||||
"signinWithPasskey": string;
|
||||
/**
|
||||
* SSOでログイン
|
||||
*/
|
||||
"signinWithSso": string;
|
||||
/**
|
||||
* 登録されていないパスキーです。
|
||||
*/
|
||||
|
|
@ -9377,6 +9381,60 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"moreDetailedGuideHere": string;
|
||||
};
|
||||
"_sso": {
|
||||
/**
|
||||
* 連携済みの外部アカウント
|
||||
*/
|
||||
"connectedAccounts": string;
|
||||
/**
|
||||
* 外部のIDプロバイダー(OIDC)アカウントをこのアカウントに連携すると、そのアカウントでもログインできるようになります。
|
||||
*/
|
||||
"description": string;
|
||||
/**
|
||||
* 外部アカウントを連携
|
||||
*/
|
||||
"link": string;
|
||||
/**
|
||||
* {name}と連携
|
||||
*/
|
||||
"linkProvider": ParameterizedString<"name">;
|
||||
/**
|
||||
* 連携を解除
|
||||
*/
|
||||
"unlink": string;
|
||||
/**
|
||||
* この外部アカウントの連携を解除しますか?解除後はこのアカウントでログインできなくなります。
|
||||
*/
|
||||
"unlinkConfirm": string;
|
||||
/**
|
||||
* 連携済みの外部アカウントはありません。
|
||||
*/
|
||||
"noLinkedAccounts": string;
|
||||
/**
|
||||
* 最終使用
|
||||
*/
|
||||
"lastUsedAt": string;
|
||||
/**
|
||||
* 外部アカウントを連携しました。
|
||||
*/
|
||||
"linked": string;
|
||||
/**
|
||||
* 連携を解除しました。
|
||||
*/
|
||||
"unlinked": string;
|
||||
/**
|
||||
* 外部アカウントの連携に失敗しました。
|
||||
*/
|
||||
"linkFailed": string;
|
||||
/**
|
||||
* この外部アカウントは既に別のアカウントに連携されています。
|
||||
*/
|
||||
"alreadyLinkedToOther": string;
|
||||
/**
|
||||
* セキュリティ設定に戻る
|
||||
*/
|
||||
"backToSecuritySettings": string;
|
||||
};
|
||||
"_permissions": {
|
||||
/**
|
||||
* アカウントの情報を見る
|
||||
|
|
|
|||
|
|
@ -1994,6 +1994,10 @@ declare namespace entities {
|
|||
IRevokeTokenRequest,
|
||||
ISigninHistoryRequest,
|
||||
ISigninHistoryResponse,
|
||||
ISsoOidcGenerateLinkUrlRequest,
|
||||
ISsoOidcGenerateLinkUrlResponse,
|
||||
ISsoOidcListResponse,
|
||||
ISsoOidcUnlinkRequest,
|
||||
IUnpinRequest,
|
||||
IUnpinResponse,
|
||||
IUpdateRequest,
|
||||
|
|
@ -2774,6 +2778,18 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200
|
|||
// @public (undocumented)
|
||||
function isPureRenote(note: Note): note is PureRenote;
|
||||
|
||||
// @public (undocumented)
|
||||
type ISsoOidcGenerateLinkUrlRequest = operations['i___sso___oidc___generate-link-url']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type ISsoOidcGenerateLinkUrlResponse = operations['i___sso___oidc___generate-link-url']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type ISsoOidcListResponse = operations['i___sso___oidc___list']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type ISsoOidcUnlinkRequest = operations['i___sso___oidc___unlink']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
export interface IStream extends EventEmitter<StreamEvents> {
|
||||
// (undocumented)
|
||||
|
|
|
|||
|
|
@ -3425,6 +3425,42 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||
* **Credential required**: *Yes*
|
||||
*/
|
||||
request<E extends 'i/sso/oidc/generate-link-url', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||
* **Credential required**: *Yes*
|
||||
*/
|
||||
request<E extends 'i/sso/oidc/list', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||
* **Credential required**: *Yes*
|
||||
*/
|
||||
request<E extends 'i/sso/oidc/unlink', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -463,6 +463,10 @@ import type {
|
|||
IRevokeTokenRequest,
|
||||
ISigninHistoryRequest,
|
||||
ISigninHistoryResponse,
|
||||
ISsoOidcGenerateLinkUrlRequest,
|
||||
ISsoOidcGenerateLinkUrlResponse,
|
||||
ISsoOidcListResponse,
|
||||
ISsoOidcUnlinkRequest,
|
||||
IUnpinRequest,
|
||||
IUnpinResponse,
|
||||
IUpdateRequest,
|
||||
|
|
@ -974,6 +978,9 @@ export type Endpoints = {
|
|||
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
|
||||
'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse };
|
||||
'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse };
|
||||
'i/sso/oidc/generate-link-url': { req: ISsoOidcGenerateLinkUrlRequest; res: ISsoOidcGenerateLinkUrlResponse };
|
||||
'i/sso/oidc/list': { req: EmptyRequest; res: ISsoOidcListResponse };
|
||||
'i/sso/oidc/unlink': { req: ISsoOidcUnlinkRequest; res: EmptyResponse };
|
||||
'i/unpin': { req: IUnpinRequest; res: IUnpinResponse };
|
||||
'i/update': { req: IUpdateRequest; res: IUpdateResponse };
|
||||
'i/update-email': { req: IUpdateEmailRequest; res: IUpdateEmailResponse };
|
||||
|
|
|
|||
|
|
@ -466,6 +466,10 @@ export type IRegistrySetRequest = operations['i___registry___set']['requestBody'
|
|||
export type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json'];
|
||||
export type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
|
||||
export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json'];
|
||||
export type ISsoOidcGenerateLinkUrlRequest = operations['i___sso___oidc___generate-link-url']['requestBody']['content']['application/json'];
|
||||
export type ISsoOidcGenerateLinkUrlResponse = operations['i___sso___oidc___generate-link-url']['responses']['200']['content']['application/json'];
|
||||
export type ISsoOidcListResponse = operations['i___sso___oidc___list']['responses']['200']['content']['application/json'];
|
||||
export type ISsoOidcUnlinkRequest = operations['i___sso___oidc___unlink']['requestBody']['content']['application/json'];
|
||||
export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];
|
||||
export type IUnpinResponse = operations['i___unpin']['responses']['200']['content']['application/json'];
|
||||
export type IUpdateRequest = operations['i___update']['requestBody']['content']['application/json'];
|
||||
|
|
|
|||
|
|
@ -2810,6 +2810,36 @@ export type paths = {
|
|||
*/
|
||||
post: operations['i___signin-history'];
|
||||
};
|
||||
'/i/sso/oidc/generate-link-url': {
|
||||
/**
|
||||
* i/sso/oidc/generate-link-url
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||
* **Credential required**: *Yes*
|
||||
*/
|
||||
post: operations['i___sso___oidc___generate-link-url'];
|
||||
};
|
||||
'/i/sso/oidc/list': {
|
||||
/**
|
||||
* i/sso/oidc/list
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||
* **Credential required**: *Yes*
|
||||
*/
|
||||
post: operations['i___sso___oidc___list'];
|
||||
};
|
||||
'/i/sso/oidc/unlink': {
|
||||
/**
|
||||
* i/sso/oidc/unlink
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
|
||||
* **Credential required**: *Yes*
|
||||
*/
|
||||
post: operations['i___sso___oidc___unlink'];
|
||||
};
|
||||
'/i/unpin': {
|
||||
/**
|
||||
* i/unpin
|
||||
|
|
@ -5555,6 +5585,8 @@ export type components = {
|
|||
maxFileSize: number;
|
||||
/** @enum {string} */
|
||||
federation: 'all' | 'specified' | 'none';
|
||||
ssoOidcEnabled: boolean;
|
||||
ssoOidcName: string | null;
|
||||
};
|
||||
MetaDetailedOnly: {
|
||||
features?: {
|
||||
|
|
@ -27691,6 +27723,206 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
'i___sso___oidc___generate-link-url': {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
password: string;
|
||||
token?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
i___sso___oidc___list: {
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
lastUsedAt: string | null;
|
||||
issuer: string;
|
||||
sub: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
i___sso___oidc___unlink: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
/** Format: misskey:id */
|
||||
identityId: string;
|
||||
password: string;
|
||||
token?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (without any results) */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
i___unpin: {
|
||||
requestBody: {
|
||||
content: {
|
||||
|
|
|
|||
171
pnpm-lock.yaml
generated
171
pnpm-lock.yaml
generated
|
|
@ -80,7 +80,7 @@ importers:
|
|||
version: 11.5.2
|
||||
start-server-and-test:
|
||||
specifier: 3.0.9
|
||||
version: 3.0.9
|
||||
version: 3.0.9(supports-color@5.5.0)
|
||||
typescript:
|
||||
specifier: 5.9.3
|
||||
version: 5.9.3
|
||||
|
|
@ -292,6 +292,9 @@ importers:
|
|||
nsfwjs:
|
||||
specifier: 4.3.0
|
||||
version: 4.3.0(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(buffer@6.0.3)
|
||||
openid-client:
|
||||
specifier: 6.8.4
|
||||
version: 6.8.4
|
||||
os-utils:
|
||||
specifier: 0.0.14
|
||||
version: 0.0.14
|
||||
|
|
@ -475,10 +478,10 @@ importers:
|
|||
version: 8.18.1
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: 8.61.0
|
||||
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: 8.61.0
|
||||
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
version: 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(vitest@4.1.8)
|
||||
|
|
@ -493,7 +496,7 @@ importers:
|
|||
version: 10.1.0
|
||||
eslint-plugin-import:
|
||||
specifier: 2.32.0
|
||||
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
|
||||
execa:
|
||||
specifier: 9.6.1
|
||||
version: 9.6.1
|
||||
|
|
@ -846,10 +849,10 @@ importers:
|
|||
version: 1.4.6
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: 8.61.0
|
||||
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: 8.61.0
|
||||
version: 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(vitest@4.1.8)
|
||||
|
|
@ -870,10 +873,10 @@ importers:
|
|||
version: 15.17.0
|
||||
eslint-plugin-import:
|
||||
specifier: 2.32.0
|
||||
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1)
|
||||
version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
eslint-plugin-vue:
|
||||
specifier: 10.9.2
|
||||
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2))
|
||||
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
|
||||
happy-dom:
|
||||
specifier: 20.10.2
|
||||
version: 20.10.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
|
|
@ -924,7 +927,7 @@ importers:
|
|||
version: 3.0.5
|
||||
start-server-and-test:
|
||||
specifier: 3.0.9
|
||||
version: 3.0.9(supports-color@10.2.2)
|
||||
version: 3.0.9
|
||||
storybook:
|
||||
specifier: 10.4.3
|
||||
version: 10.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.0)(@types/react@19.2.14)(bufferutil@4.1.0)(prettier@3.8.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@6.0.6)
|
||||
|
|
@ -954,7 +957,7 @@ importers:
|
|||
version: 3.3.4
|
||||
vue-eslint-parser:
|
||||
specifier: 10.4.1
|
||||
version: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
|
||||
version: 10.4.1(eslint@9.39.4)
|
||||
vue-tsc:
|
||||
specifier: 3.3.4
|
||||
version: 3.3.4(typescript@5.9.3)
|
||||
|
|
@ -1179,10 +1182,10 @@ importers:
|
|||
version: 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
eslint-plugin-vue:
|
||||
specifier: 10.9.2
|
||||
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4))
|
||||
version: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@5.5.0))
|
||||
vue-eslint-parser:
|
||||
specifier: 10.4.1
|
||||
version: 10.4.1(eslint@9.39.4)
|
||||
version: 10.4.1(eslint@9.39.4)(supports-color@5.5.0)
|
||||
|
||||
packages/i18n:
|
||||
dependencies:
|
||||
|
|
@ -4546,7 +4549,7 @@ packages:
|
|||
engines: {node: '>= 14'}
|
||||
|
||||
aiscript-vscode@https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67:
|
||||
resolution: {gitHosted: true, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67}
|
||||
resolution: {gitHosted: true, integrity: sha512-S4eSTHasZz29AMlnU2/zdGP8zikiDiYfYW9kNooAfwVo8OghXdxKuTDDKDAjWsbBxa1+P4uQHa4BNk9MY74rJQ==, tarball: https://codeload.github.com/aiscript-dev/aiscript-vscode/tar.gz/1dc7f60cda78d030dadfc518a33c472202b2ef67}
|
||||
version: 0.1.16
|
||||
engines: {vscode: ^1.83.0}
|
||||
|
||||
|
|
@ -6782,6 +6785,9 @@ packages:
|
|||
resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
js-beautify@1.15.4:
|
||||
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -7632,6 +7638,9 @@ packages:
|
|||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
oauth4webapi@3.8.6:
|
||||
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -7721,6 +7730,9 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: ^5.x
|
||||
|
||||
openid-client@6.8.4:
|
||||
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -9074,7 +9086,7 @@ packages:
|
|||
engines: {node: '>= 0.4'}
|
||||
|
||||
storybook-addon-misskey-theme@https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640:
|
||||
resolution: {gitHosted: true, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640}
|
||||
resolution: {gitHosted: true, integrity: sha512-QaH1uZSlApQ2CZPkHfhmNm89I92L02s3MdbUPG66TmAyqMaqzxd/AvobORBjtTZ0ymUSa3ii482dRXi+fFb19w==, tarball: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640}
|
||||
version: 0.0.0
|
||||
peerDependencies:
|
||||
'@storybook/blocks': ^7.0.0-rc.4
|
||||
|
|
@ -10485,7 +10497,7 @@ snapshots:
|
|||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/helper-compilation-targets': 7.28.6
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))
|
||||
'@babel/helpers': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
|
|
@ -10493,7 +10505,7 @@ snapshots:
|
|||
'@babel/types': 7.29.0
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
|
|
@ -10505,7 +10517,7 @@ snapshots:
|
|||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/helper-compilation-targets': 7.28.6
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))
|
||||
'@babel/helpers': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
|
|
@ -10545,7 +10557,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
|
||||
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@10.2.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0(supports-color@10.2.2)
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
|
|
@ -10587,7 +10599,7 @@ snapshots:
|
|||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -10913,7 +10925,7 @@ snapshots:
|
|||
'@eslint/config-array@0.21.2':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -10933,7 +10945,7 @@ snapshots:
|
|||
'@eslint/eslintrc@3.3.5':
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
|
|
@ -12826,7 +12838,7 @@ snapshots:
|
|||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -13077,12 +13089,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 24.13.1
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
eslint: 9.39.4
|
||||
|
|
@ -13109,19 +13121,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
eslint: 9.39.4
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
|
|
@ -13133,11 +13133,14 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.61.0(supports-color@10.2.2)(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.61.0
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 9.39.4
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -13146,7 +13149,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -13160,12 +13163,12 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)':
|
||||
'@typescript-eslint/type-utils@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 9.39.4
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
|
@ -13177,7 +13180,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 9.39.4
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
|
@ -13186,28 +13189,13 @@ snapshots:
|
|||
|
||||
'@typescript-eslint/types@8.61.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.61.0(supports-color@10.2.2)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.61.0(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.4
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.61.0
|
||||
'@typescript-eslint/visitor-keys': 8.61.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.4
|
||||
tinyglobby: 0.2.17
|
||||
|
|
@ -13810,9 +13798,9 @@ snapshots:
|
|||
|
||||
aws4@1.13.2: {}
|
||||
|
||||
axios@1.16.0(debug@4.4.3(supports-color@10.2.2)):
|
||||
axios@1.16.0(debug@4.4.3(supports-color@5.5.0)):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2))
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@5.5.0))
|
||||
form-data: 4.0.6
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -14943,11 +14931,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -14963,7 +14951,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint@9.39.4)(supports-color@8.1.1):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -14974,7 +14962,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10(supports-color@8.1.1)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@9.39.4)(supports-color@8.1.1)
|
||||
hasown: 2.0.4
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -14986,7 +14974,7 @@ snapshots:
|
|||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
|
|
@ -15021,7 +15009,7 @@ snapshots:
|
|||
- eslint-import-resolver-webpack
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2)):
|
||||
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@5.5.0)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
eslint: 9.39.4
|
||||
|
|
@ -15029,11 +15017,11 @@ snapshots:
|
|||
nth-check: 2.1.1
|
||||
postcss-selector-parser: 7.1.1
|
||||
semver: 7.8.4
|
||||
vue-eslint-parser: 10.4.1(eslint@9.39.4)(supports-color@10.2.2)
|
||||
vue-eslint-parser: 10.4.1(eslint@9.39.4)(supports-color@5.5.0)
|
||||
xml-name-validator: 4.0.0
|
||||
optionalDependencies:
|
||||
'@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(supports-color@10.2.2)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.61.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
|
||||
eslint-plugin-vue@10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4))(@typescript-eslint/parser@8.61.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.1(eslint@9.39.4)):
|
||||
dependencies:
|
||||
|
|
@ -15086,7 +15074,7 @@ snapshots:
|
|||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
|
@ -15438,13 +15426,13 @@ snapshots:
|
|||
async: 0.2.10
|
||||
which: 1.3.1
|
||||
|
||||
follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)):
|
||||
follow-redirects@1.16.0(debug@4.4.3(supports-color@5.5.0)):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
|
||||
follow-redirects@1.16.0(debug@4.4.3):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
|
|
@ -16175,6 +16163,8 @@ snapshots:
|
|||
'@hapi/topo': 6.0.2
|
||||
'@standard-schema/spec': 1.1.0
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
js-beautify@1.15.4:
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
|
|
@ -16840,7 +16830,7 @@ snapshots:
|
|||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
|
|
@ -17211,6 +17201,8 @@ snapshots:
|
|||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
oauth4webapi@3.8.6: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
|
@ -17320,6 +17312,11 @@ snapshots:
|
|||
typescript: 5.9.3
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
openid-client@6.8.4:
|
||||
dependencies:
|
||||
jose: 6.2.3
|
||||
oauth4webapi: 3.8.6
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
|
@ -18195,7 +18192,7 @@ snapshots:
|
|||
|
||||
require-in-the-middle@8.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
module-details-from-path: 1.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -18504,7 +18501,7 @@ snapshots:
|
|||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
|
|
@ -18804,7 +18801,7 @@ snapshots:
|
|||
dependencies:
|
||||
arg: 5.0.2
|
||||
check-more-types: 2.24.0
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
execa: 5.1.1
|
||||
lazy-ass: 2.0.3
|
||||
tree-kill: 1.2.2
|
||||
|
|
@ -18812,15 +18809,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
start-server-and-test@3.0.9(supports-color@10.2.2):
|
||||
start-server-and-test@3.0.9(supports-color@5.5.0):
|
||||
dependencies:
|
||||
arg: 5.0.2
|
||||
check-more-types: 2.24.0
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
execa: 5.1.1
|
||||
lazy-ass: 2.0.3
|
||||
tree-kill: 1.2.2
|
||||
wait-on: 9.0.10(debug@4.4.3(supports-color@10.2.2))
|
||||
wait-on: 9.0.10(debug@4.4.3(supports-color@5.5.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -19640,7 +19637,7 @@ snapshots:
|
|||
|
||||
vue-eslint-parser@10.4.1(eslint@9.39.4):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
eslint: 9.39.4
|
||||
eslint-scope: 9.1.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
|
@ -19650,9 +19647,9 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@10.2.2):
|
||||
vue-eslint-parser@10.4.1(eslint@9.39.4)(supports-color@5.5.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 9.39.4
|
||||
eslint-scope: 9.1.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
|
@ -19682,9 +19679,9 @@ snapshots:
|
|||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
wait-on@9.0.10(debug@4.4.3(supports-color@10.2.2)):
|
||||
wait-on@9.0.10(debug@4.4.3(supports-color@5.5.0)):
|
||||
dependencies:
|
||||
axios: 1.16.0(debug@4.4.3(supports-color@10.2.2))
|
||||
axios: 1.16.0(debug@4.4.3(supports-color@5.5.0))
|
||||
joi: 18.2.1
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue