diff --git a/.claude/hooks/oxlint-fix.mjs b/.claude/hooks/oxlint-fix.mjs index 8752fdc..c37dc10 100644 --- a/.claude/hooks/oxlint-fix.mjs +++ b/.claude/hooks/oxlint-fix.mjs @@ -6,36 +6,36 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(); const file = await readFilePath(); if (!file || !/\.(tsx?|jsx?|mjs|cjs)$/.test(file)) { - process.exit(0); + process.exit(0); } try { - execFileSync('pnpm', ['exec', 'oxlint', '--fix', file], { - cwd: projectDir, - stdio: 'ignore', - }); + execFileSync('pnpm', ['exec', 'oxlint', '--fix', file], { + cwd: projectDir, + stdio: 'ignore', + }); } catch { - // 整形に失敗してもツール結果はブロックしない。 + // 整形に失敗してもツール結果はブロックしない。 } process.exit(0); async function readFilePath() { - const raw = await readStdin(); - try { - return JSON.parse(raw)?.tool_input?.file_path ?? ''; - } catch { - return ''; - } + const raw = await readStdin(); + try { + return JSON.parse(raw)?.tool_input?.file_path ?? ''; + } catch { + return ''; + } } function readStdin() { - return new Promise((resolve) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk) => { - data += chunk; - }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', () => resolve('')); - }); + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', () => resolve('')); + }); } diff --git a/.claude/hooks/protect-deps.mjs b/.claude/hooks/protect-deps.mjs index 18315d8..b1ceebe 100644 --- a/.claude/hooks/protect-deps.mjs +++ b/.claude/hooks/protect-deps.mjs @@ -8,41 +8,41 @@ const file = await readFilePath(); const name = basename(file); if (name === 'pnpm-lock.yaml') { - console.error( - 'pnpm-lock.yaml は直接編集しないでください。依存は package.json / catalog を変更し、pnpm install で lockfile を再生成してください。', - ); - process.exit(2); // ブロック。stderr が Claude に渡る。 + console.error( + 'pnpm-lock.yaml は直接編集しないでください。依存は package.json / catalog を変更し、pnpm install で lockfile を再生成してください。', + ); + process.exit(2); // ブロック。stderr が Claude に渡る。 } if (name === 'pnpm-workspace.yaml' || name === 'Dockerfile') { - process.stdout.write( - JSON.stringify({ - systemMessage: + process.stdout.write( + JSON.stringify({ + systemMessage: '[native-deps-guard] catalog / Dockerfile を編集します。tfjs-node 4.22(N-API v8) / Node22 / avx2+fma / glibc 固定との整合を /native-deps-guard で確認してください。', - }), - ); - process.exit(0); // 許可(リマインドのみ)。 + }), + ); + process.exit(0); // 許可(リマインドのみ)。 } process.exit(0); async function readFilePath() { - const raw = await readStdin(); - try { - return JSON.parse(raw)?.tool_input?.file_path ?? ''; - } catch { - return ''; - } + const raw = await readStdin(); + try { + return JSON.parse(raw)?.tool_input?.file_path ?? ''; + } catch { + return ''; + } } function readStdin() { - return new Promise((resolve) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk) => { - data += chunk; - }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', () => resolve('')); - }); + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', () => resolve('')); + }); } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..064de7d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 2 +tab_width = 2 + +[*.{json,jsonc,json5,yml,yaml,md}] +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 97246cc..d0bb2b8 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -14,50 +14,50 @@ import type { AppDeps, AppEnv } from './types.js'; * 上限超過の非 multipart ボディは 415 ではなく 413 になる(これが正本契約)。 */ export function createApp(deps: AppDeps): Hono { - const app = new Hono(); - - app.use('*', requestContext(deps)); - - // ヘルスチェック。auth より前に登録して認証不要で公開する(漏れる情報は readiness のみ)。 - // 200 ok = 受付可能。503 unavailable = 受付不能で、reason に理由を載せる: - // - model_unavailable: 起動時にモデルロード失敗(CPU 非対応 / import・load 失敗)。 - // - inference_saturated: 全スロットが推論タイムアウト後も解放されない(hung)=容量ゼロ。 - // orchestrator の liveness probe にこれを拾わせ、プロセス再起動で回復させる想定。 - // 推論経路には一切触れない読み取り専用エンドポイント。 - app.get('/health', (c) => { - if (!deps.classifier.available) { - return c.json({ status: 'unavailable' as const, reason: 'model_unavailable' as const }, 503); - } - if (deps.health.saturated) { - return c.json({ status: 'unavailable' as const, reason: 'inference_saturated' as const }, 503); - } - return c.json({ status: 'ok' as const }, 200); - }); - - app.use('*', auth(deps)); - app.use( - '/v1/detect-images', - bodyLimit({ - maxSize: deps.config.maxBodySize, - onError: (c) => - c.json( - { - success: false as const, - error: { code: 'REQUEST_TOO_LARGE' as const, message: `body exceeds ${deps.config.maxBodySize} bytes` }, - }, - 413, - ), - }), - ); - - app.post('/v1/detect-images', detectImagesRoute(deps)); - - app.notFound((c) => fail(c, 'INVALID_REQUEST', `no route for ${c.req.method} ${c.req.path}`)); - - app.onError((err, c) => { - deps.logger.error({ err }, 'unhandled error in request pipeline'); - return fail(c, 'DETECTION_FAILED', 'internal server error'); - }); - - return app; + const app = new Hono(); + + app.use('*', requestContext(deps)); + + // ヘルスチェック。auth より前に登録して認証不要で公開する(漏れる情報は readiness のみ)。 + // 200 ok = 受付可能。503 unavailable = 受付不能で、reason に理由を載せる: + // - model_unavailable: 起動時にモデルロード失敗(CPU 非対応 / import・load 失敗)。 + // - inference_saturated: 全スロットが推論タイムアウト後も解放されない(hung)=容量ゼロ。 + // orchestrator の liveness probe にこれを拾わせ、プロセス再起動で回復させる想定。 + // 推論経路には一切触れない読み取り専用エンドポイント。 + app.get('/health', (c) => { + if (!deps.classifier.available) { + return c.json({ status: 'unavailable' as const, reason: 'model_unavailable' as const }, 503); + } + if (deps.health.saturated) { + return c.json({ status: 'unavailable' as const, reason: 'inference_saturated' as const }, 503); + } + return c.json({ status: 'ok' as const }, 200); + }); + + app.use('*', auth(deps)); + app.use( + '/v1/detect-images', + bodyLimit({ + maxSize: deps.config.maxBodySize, + onError: (c) => + c.json( + { + success: false as const, + error: { code: 'REQUEST_TOO_LARGE' as const, message: `body exceeds ${deps.config.maxBodySize} bytes` }, + }, + 413, + ), + }), + ); + + app.post('/v1/detect-images', detectImagesRoute(deps)); + + app.notFound((c) => fail(c, 'INVALID_REQUEST', `no route for ${c.req.method} ${c.req.path}`)); + + app.onError((err, c) => { + deps.logger.error({ err }, 'unhandled error in request pipeline'); + return fail(c, 'DETECTION_FAILED', 'internal server error'); + }); + + return app; } diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 41acfce..1f37569 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -1,12 +1,12 @@ import { createAdaptorServer, serve } from '@hono/node-server'; import { - createClassifier, - InferenceHealth, - type ResolvedConfig, - SaturationMonitor, - Semaphore, - unknownConfigKeys, - validateConfig, + createClassifier, + InferenceHealth, + type ResolvedConfig, + SaturationMonitor, + Semaphore, + unknownConfigKeys, + validateConfig, } from '@misskey-sensitive-detector/core'; import type { Logger } from 'pino'; import { createApp } from './app.js'; @@ -30,8 +30,8 @@ const SATURATION_CHECK_INTERVAL_MS = 10_000; const SATURATION_EXIT_AFTER_CONSECUTIVE = 3; // ≒30s 継続でようやく exit function onListenError(err: unknown, logger: Logger): void { - logger.error({ err }, 'server failed to listen'); - process.exit(1); + logger.error({ err }, 'server failed to listen'); + process.exit(1); } /** @@ -41,79 +41,79 @@ function onListenError(err: unknown, logger: Logger): void { * - apiKey が短すぎる(推測されやすい)。 */ function warnConfigCoherence(raw: unknown, config: ResolvedConfig, logger: Logger): void { - const unknownKeys = unknownConfigKeys(raw); - if (unknownKeys.length > 0) { - logger.warn( - { unknownKeys }, - 'config has unknown keys (typo?); they are ignored and defaults apply. Check spelling against config.example.mjs', - ); - } - - const capacity = config.maxParts * config.maxBinarySize; - if (config.maxBodySize < capacity) { - logger.warn( - `config.maxBodySize (${config.maxBodySize}) < maxParts*maxBinarySize (${capacity}); ` + + const unknownKeys = unknownConfigKeys(raw); + if (unknownKeys.length > 0) { + logger.warn( + { unknownKeys }, + 'config has unknown keys (typo?); they are ignored and defaults apply. Check spelling against config.example.mjs', + ); + } + + const capacity = config.maxParts * config.maxBinarySize; + if (config.maxBodySize < capacity) { + logger.warn( + `config.maxBodySize (${config.maxBodySize}) < maxParts*maxBinarySize (${capacity}); ` + 'full-size multipart requests will be rejected by the body limit before per-part checks run', - ); - } - if (config.apiKey !== undefined && config.apiKey.length < 16) { - logger.warn('config.apiKey is shorter than 16 chars; use a long random secret (e.g. `openssl rand -hex 32`)'); - } + ); + } + if (config.apiKey !== undefined && config.apiKey.length < 16) { + logger.warn('config.apiKey is shorter than 16 chars; use a long random secret (e.g. `openssl rand -hex 32`)'); + } } /** http.Server にのみ受信タイムアウトを設定する(ServerType の http2 派生には該当プロパティが無い)。 */ function applyReceiveTimeouts(server: RunningServer): void { - if ('requestTimeout' in server) { - server.headersTimeout = HEADERS_TIMEOUT_MS; - server.requestTimeout = REQUEST_RECEIVE_TIMEOUT_MS; - } + if ('requestTimeout' in server) { + server.headersTimeout = HEADERS_TIMEOUT_MS; + server.requestTimeout = REQUEST_RECEIVE_TIMEOUT_MS; + } } function startServer(app: ReturnType, config: ResolvedConfig, logger: Logger): RunningServer { - if (config.listen.kind === 'port') { - const { port, host } = config.listen; - const server = serve({ fetch: app.fetch, port, hostname: host }, (info) => { - logger.info(`listening on http://${host}:${info.port}`); - }); - server.on('error', (err) => onListenError(err, logger)); - return server; - } - - const { socket } = config.listen; - const server = createAdaptorServer({ fetch: app.fetch }); - server.on('error', (err) => onListenError(err, logger)); - server.listen({ path: socket }, () => { - logger.info(`listening on unix socket ${socket}`); - }); - return server; + if (config.listen.kind === 'port') { + const { port, host } = config.listen; + const server = serve({ fetch: app.fetch, port, hostname: host }, (info) => { + logger.info(`listening on http://${host}:${info.port}`); + }); + server.on('error', (err) => onListenError(err, logger)); + return server; + } + + const { socket } = config.listen; + const server = createAdaptorServer({ fetch: app.fetch }); + server.on('error', (err) => onListenError(err, logger)); + server.listen({ path: socket }, () => { + logger.info(`listening on unix socket ${socket}`); + }); + return server; } function installRuntimeHandlers(server: RunningServer, logger: Logger, onShutdown?: () => void): void { - const shutdown = (signal: string): void => { - onShutdown?.(); // watchdog 等を止めてから閉じる(shutdown 中に self-exit(1) へ化けるのを防ぐ)。 - logger.info(`received ${signal}, shutting down`); - server.close((err) => { - if (err) { - logger.error({ err }, 'error while closing server'); - process.exit(1); - } - process.exit(0); - }); - // 一定時間で強制終了(接続が掴まれて閉じない場合のフォールバック)。 - setTimeout(() => process.exit(0), 5_000).unref(); - }; - - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('unhandledRejection', (reason) => { - logger.error({ reason }, 'unhandled promise rejection'); - }); - // uncaughtException 後はプロセス状態が不定なので、ログを残して終了し再起動に委ねる - // (compose の restart: unless-stopped / オーケストレータが復帰させる)。 - process.on('uncaughtException', (err) => { - logger.error({ err }, 'uncaught exception; exiting for restart'); - process.exit(1); - }); + const shutdown = (signal: string): void => { + onShutdown?.(); // watchdog 等を止めてから閉じる(shutdown 中に self-exit(1) へ化けるのを防ぐ)。 + logger.info(`received ${signal}, shutting down`); + server.close((err) => { + if (err) { + logger.error({ err }, 'error while closing server'); + process.exit(1); + } + process.exit(0); + }); + // 一定時間で強制終了(接続が掴まれて閉じない場合のフォールバック)。 + setTimeout(() => process.exit(0), 5_000).unref(); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('unhandledRejection', (reason) => { + logger.error({ reason }, 'unhandled promise rejection'); + }); + // uncaughtException 後はプロセス状態が不定なので、ログを残して終了し再起動に委ねる + // (compose の restart: unless-stopped / オーケストレータが復帰させる)。 + process.on('uncaughtException', (err) => { + logger.error({ err }, 'uncaught exception; exiting for restart'); + process.exit(1); + }); } /** @@ -123,22 +123,22 @@ function installRuntimeHandlers(server: RunningServer, logger: Logger, onShutdow * 戻り値は watchdog を止める関数(graceful shutdown 開始時に呼ぶ)。 */ function installSaturationWatchdog(health: InferenceHealth, logger: Logger): () => void { - const monitor = new SaturationMonitor(SATURATION_EXIT_AFTER_CONSECUTIVE); - const timer = setInterval(() => { - const shouldExit = monitor.observe(health.saturated); - if (health.saturated) { - logger.warn( - { stuckCount: health.stuckCount, consecutive: monitor.consecutiveCount }, - 'inference saturated; all slots stuck on hung jobs', - ); - } - if (shouldExit) { - logger.error('inference saturated too long; exiting for supervisor restart'); - process.exit(1); - } - }, SATURATION_CHECK_INTERVAL_MS); - timer.unref(); - return () => clearInterval(timer); + const monitor = new SaturationMonitor(SATURATION_EXIT_AFTER_CONSECUTIVE); + const timer = setInterval(() => { + const shouldExit = monitor.observe(health.saturated); + if (health.saturated) { + logger.warn( + { stuckCount: health.stuckCount, consecutive: monitor.consecutiveCount }, + 'inference saturated; all slots stuck on hung jobs', + ); + } + if (shouldExit) { + logger.error('inference saturated too long; exiting for supervisor restart'); + process.exit(1); + } + }, SATURATION_CHECK_INTERVAL_MS); + timer.unref(); + return () => clearInterval(timer); } /** @@ -147,31 +147,31 @@ function installSaturationWatchdog(health: InferenceHealth, logger: Logger): () * - モデルロード失敗は致命的ではない(unavailable な classifier として常駐し、毎回 503 を返す)。 */ export async function bootstrap(argv: readonly string[], basedir = process.cwd()): Promise { - const logger = createLogger(); - - const configPath = resolveConfigPath(argv); - if (!configPath) { - throw new Error('no config specified; pass --config or set SENSITIVE_DETECTOR_CONFIG'); - } - - const raw = await loadConfigModule(configPath, basedir); - const config = validateConfig(raw); // ConfigError は致命的 - logger.info(`loaded config from ${configPath}`); - warnConfigCoherence(raw, config, logger); - - // 起動時に 1 回だけモデルをロードする(失敗しても常駐し続ける)。 - const intraOpNumThreads = parseInt(process.env.SENSITIVE_DETECTOR_THREADS ?? '1', 10) || undefined; - const classifier = await createClassifier(config.modelDir, { logger, intraOpNumThreads }); - if (!classifier.available) { - logger.warn('model is unavailable; every /v1/detect-image request will return MODEL_UNAVAILABLE (503)'); - } - - const semaphore = new Semaphore(config.maxConcurrentJobs); - const health = new InferenceHealth(config.maxConcurrentJobs); - const app = createApp({ config, classifier, semaphore, health, logger }); - - const server = startServer(app, config, logger); - applyReceiveTimeouts(server); - const stopWatchdog = installSaturationWatchdog(health, logger); - installRuntimeHandlers(server, logger, stopWatchdog); + const logger = createLogger(); + + const configPath = resolveConfigPath(argv); + if (!configPath) { + throw new Error('no config specified; pass --config or set SENSITIVE_DETECTOR_CONFIG'); + } + + const raw = await loadConfigModule(configPath, basedir); + const config = validateConfig(raw); // ConfigError は致命的 + logger.info(`loaded config from ${configPath}`); + warnConfigCoherence(raw, config, logger); + + // 起動時に 1 回だけモデルをロードする(失敗しても常駐し続ける)。 + const intraOpNumThreads = parseInt(process.env.SENSITIVE_DETECTOR_THREADS ?? '1', 10) || undefined; + const classifier = await createClassifier(config.modelDir, { logger, intraOpNumThreads }); + if (!classifier.available) { + logger.warn('model is unavailable; every /v1/detect-image request will return MODEL_UNAVAILABLE (503)'); + } + + const semaphore = new Semaphore(config.maxConcurrentJobs); + const health = new InferenceHealth(config.maxConcurrentJobs); + const app = createApp({ config, classifier, semaphore, health, logger }); + + const server = startServer(app, config, logger); + applyReceiveTimeouts(server); + const stopWatchdog = installSaturationWatchdog(health, logger); + installRuntimeHandlers(server, logger, stopWatchdog); } diff --git a/apps/server/src/config/load-config.ts b/apps/server/src/config/load-config.ts index 15859fa..56134a6 100644 --- a/apps/server/src/config/load-config.ts +++ b/apps/server/src/config/load-config.ts @@ -6,17 +6,17 @@ import { pathToFileURL } from 'node:url'; * config ファイルのパスを解決する。見つからなければ undefined。 */ export function resolveConfigPath(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): string | undefined { - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === '--config') { - return argv[i + 1]; - } - if (arg?.startsWith('--config=')) { - return arg.slice('--config='.length); - } - } - const fromEnv = env.SENSITIVE_DETECTOR_CONFIG; - return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--config') { + return argv[i + 1]; + } + if (arg?.startsWith('--config=')) { + return arg.slice('--config='.length); + } + } + const fromEnv = env.SENSITIVE_DETECTOR_CONFIG; + return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; } /** @@ -26,11 +26,11 @@ export function resolveConfigPath(argv: readonly string[], env: NodeJS.ProcessEn * CLI 経由で使う場合はコマンド実行時の cwd を渡すこと(サーバープロセス自体の cwd ではなく)。 */ export async function loadConfigModule(configPath: string, basedir = process.cwd()): Promise { - const absolute = isAbsolute(configPath) ? configPath : resolve(basedir, configPath); - const moduleUrl = pathToFileURL(absolute).href; - const imported = (await import(moduleUrl)) as { default?: unknown }; - if (imported.default === undefined) { - throw new Error(`config module "${configPath}" must have a default export`); - } - return imported.default; + const absolute = isAbsolute(configPath) ? configPath : resolve(basedir, configPath); + const moduleUrl = pathToFileURL(absolute).href; + const imported = (await import(moduleUrl)) as { default?: unknown }; + if (imported.default === undefined) { + throw new Error(`config module "${configPath}" must have a default export`); + } + return imported.default; } diff --git a/apps/server/src/lib/concurrency.ts b/apps/server/src/lib/concurrency.ts index 852bad4..132878b 100644 --- a/apps/server/src/lib/concurrency.ts +++ b/apps/server/src/lib/concurrency.ts @@ -9,28 +9,28 @@ * 無駄な処理を始めない(実行中の `fn` は中断できないので走り切る)。`fn` を total に保てば reject しない。 */ export async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T, index: number) => Promise, + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, ): Promise { - const results = new Array(items.length); - let cursor = 0; - let failed = false; + const results = Array.from({ length: items.length }); + let cursor = 0; + let failed = false; - const worker = async (): Promise => { - while (!failed && cursor < items.length) { - const index = cursor; - cursor += 1; - try { - results[index] = await fn(items[index] as T, index); - } catch (err) { - failed = true; // 他 worker に後続 item の取得を止めさせ、最初のエラーを伝播する。 - throw err; - } - } - }; + const worker = async (): Promise => { + while (!failed && cursor < items.length) { + const index = cursor; + cursor += 1; + try { + results[index] = await fn(items[index] as T, index); + } catch (err) { + failed = true; // 他 worker に後続 item の取得を止めさせ、最初のエラーを伝播する。 + throw err; + } + } + }; - const workerCount = Math.max(1, Math.min(limit, items.length)); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - return results; + const workerCount = Math.max(1, Math.min(limit, items.length)); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; } diff --git a/apps/server/src/lib/error-mapping.ts b/apps/server/src/lib/error-mapping.ts index be59783..654fe30 100644 --- a/apps/server/src/lib/error-mapping.ts +++ b/apps/server/src/lib/error-mapping.ts @@ -5,15 +5,15 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'; * DetectErrorCode → HTTP ステータスの単一表。全コードを網羅する(テストで保証)。 */ const STATUS_BY_CODE: Record = { - AUTHENTICATION_REQUIRED: 401, - INVALID_REQUEST: 400, - UNSUPPORTED_MEDIA_TYPE: 415, - REQUEST_TOO_LARGE: 413, - IMAGE_DECODE_FAILED: 422, - MODEL_UNAVAILABLE: 503, - DETECTION_FAILED: 500, + AUTHENTICATION_REQUIRED: 401, + INVALID_REQUEST: 400, + UNSUPPORTED_MEDIA_TYPE: 415, + REQUEST_TOO_LARGE: 413, + IMAGE_DECODE_FAILED: 422, + MODEL_UNAVAILABLE: 503, + DETECTION_FAILED: 500, }; export function statusForCode(code: DetectErrorCode): ContentfulStatusCode { - return STATUS_BY_CODE[code]; + return STATUS_BY_CODE[code]; } diff --git a/apps/server/src/lib/image-metadata.ts b/apps/server/src/lib/image-metadata.ts index dde270f..a27503f 100644 --- a/apps/server/src/lib/image-metadata.ts +++ b/apps/server/src/lib/image-metadata.ts @@ -1,108 +1,108 @@ export type ImageDimensions = { - width: number; - height: number; + width: number; + height: number; }; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const SOF_MARKERS = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]); function parsePng(buffer: Buffer): ImageDimensions | undefined { - if (buffer.length < 33 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { - return undefined; - } - if (buffer.toString('ascii', 12, 16) !== 'IHDR') { - return undefined; - } - const width = buffer.readUInt32BE(16); - const height = buffer.readUInt32BE(20); - return width > 0 && height > 0 ? { width, height } : undefined; + if (buffer.length < 33 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + return undefined; + } + if (buffer.toString('ascii', 12, 16) !== 'IHDR') { + return undefined; + } + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return width > 0 && height > 0 ? { width, height } : undefined; } function parseGif(buffer: Buffer): ImageDimensions | undefined { - if (buffer.length < 10) { - return undefined; - } - const signature = buffer.toString('ascii', 0, 6); - if (signature !== 'GIF87a' && signature !== 'GIF89a') { - return undefined; - } - const width = buffer.readUInt16LE(6); - const height = buffer.readUInt16LE(8); - return width > 0 && height > 0 ? { width, height } : undefined; + if (buffer.length < 10) { + return undefined; + } + const signature = buffer.toString('ascii', 0, 6); + if (signature !== 'GIF87a' && signature !== 'GIF89a') { + return undefined; + } + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + return width > 0 && height > 0 ? { width, height } : undefined; } function parseBmp(buffer: Buffer): ImageDimensions | undefined { - if (buffer.length < 26 || buffer.toString('ascii', 0, 2) !== 'BM') { - return undefined; - } - const dibHeaderSize = buffer.readUInt32LE(14); - // DIB ヘッダ全体が存在しない(truncated/虚偽の dibHeaderSize)なら不正とみなす。 - if (dibHeaderSize < 12 || buffer.length < 14 + dibHeaderSize) { - return undefined; - } - if (dibHeaderSize === 12) { - const width = buffer.readUInt16LE(18); - const height = buffer.readUInt16LE(20); - return width > 0 && height > 0 ? { width, height } : undefined; - } - const width = buffer.readInt32LE(18); - const height = Math.abs(buffer.readInt32LE(22)); - return width > 0 && height > 0 ? { width, height } : undefined; + if (buffer.length < 26 || buffer.toString('ascii', 0, 2) !== 'BM') { + return undefined; + } + const dibHeaderSize = buffer.readUInt32LE(14); + // DIB ヘッダ全体が存在しない(truncated/虚偽の dibHeaderSize)なら不正とみなす。 + if (dibHeaderSize < 12 || buffer.length < 14 + dibHeaderSize) { + return undefined; + } + if (dibHeaderSize === 12) { + const width = buffer.readUInt16LE(18); + const height = buffer.readUInt16LE(20); + return width > 0 && height > 0 ? { width, height } : undefined; + } + const width = buffer.readInt32LE(18); + const height = Math.abs(buffer.readInt32LE(22)); + return width > 0 && height > 0 ? { width, height } : undefined; } function parseJpeg(buffer: Buffer): ImageDimensions | undefined { - if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { - return undefined; - } + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return undefined; + } - let offset = 2; - while (offset + 3 < buffer.length) { - if (buffer[offset] !== 0xff) { - return undefined; - } - while (buffer[offset] === 0xff) { - offset += 1; - } - const marker = buffer[offset]; - offset += 1; + let offset = 2; + while (offset + 3 < buffer.length) { + if (buffer[offset] !== 0xff) { + return undefined; + } + while (buffer[offset] === 0xff) { + offset += 1; + } + const marker = buffer[offset]; + offset += 1; - if (marker === undefined || marker === 0xd9 || marker === 0xda) { - return undefined; - } - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { - continue; - } - if (offset + 2 > buffer.length) { - return undefined; - } - const segmentLength = buffer.readUInt16BE(offset); - if (segmentLength < 2 || offset + segmentLength > buffer.length) { - return undefined; - } - if (SOF_MARKERS.has(marker)) { - if (segmentLength < 7) { - return undefined; - } - const height = buffer.readUInt16BE(offset + 3); - const width = buffer.readUInt16BE(offset + 5); - return width > 0 && height > 0 ? { width, height } : undefined; - } - offset += segmentLength; - } + if (marker === undefined || marker === 0xd9 || marker === 0xda) { + return undefined; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + continue; + } + if (offset + 2 > buffer.length) { + return undefined; + } + const segmentLength = buffer.readUInt16BE(offset); + if (segmentLength < 2 || offset + segmentLength > buffer.length) { + return undefined; + } + if (SOF_MARKERS.has(marker)) { + if (segmentLength < 7) { + return undefined; + } + const height = buffer.readUInt16BE(offset + 3); + const width = buffer.readUInt16BE(offset + 5); + return width > 0 && height > 0 ? { width, height } : undefined; + } + offset += segmentLength; + } - return undefined; + return undefined; } export function readImageDimensions(buffer: Buffer): ImageDimensions | undefined { - return parsePng(buffer) ?? parseJpeg(buffer) ?? parseGif(buffer) ?? parseBmp(buffer); + return parsePng(buffer) ?? parseJpeg(buffer) ?? parseGif(buffer) ?? parseBmp(buffer); } export function exceedsImageLimits( - dimensions: ImageDimensions, - limits: { maxImageWidth: number; maxImageHeight: number; maxImagePixels: number }, + dimensions: ImageDimensions, + limits: { maxImageWidth: number; maxImageHeight: number; maxImagePixels: number }, ): boolean { - if (dimensions.width > limits.maxImageWidth || dimensions.height > limits.maxImageHeight) { - return true; - } - return BigInt(dimensions.width) * BigInt(dimensions.height) > BigInt(limits.maxImagePixels); + if (dimensions.width > limits.maxImageWidth || dimensions.height > limits.maxImageHeight) { + return true; + } + return BigInt(dimensions.width) * BigInt(dimensions.height) > BigInt(limits.maxImagePixels); } diff --git a/apps/server/src/lib/responses.ts b/apps/server/src/lib/responses.ts index ac5b561..d0f70e4 100644 --- a/apps/server/src/lib/responses.ts +++ b/apps/server/src/lib/responses.ts @@ -8,5 +8,5 @@ import { statusForCode } from './error-mapping.js'; * 成功・部分成功のバッチ応答({ result: { results } })は detect-images ルートが直接組み立てる。 */ export function fail(c: Context, code: DetectErrorCode, message?: string) { - return c.json({ success: false as const, error: { code, message: message ?? code } }, statusForCode(code)); + return c.json({ success: false as const, error: { code, message: message ?? code } }, statusForCode(code)); } diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts index 5b22030..3bdea9c 100644 --- a/apps/server/src/logger.ts +++ b/apps/server/src/logger.ts @@ -5,11 +5,11 @@ import { type Logger, pino } from 'pino'; * 本番イメージは NODE_ENV=production を設定し、構造化 JSON ログを出力する。 */ export function createLogger(): Logger { - const usePretty = process.env.NODE_ENV !== 'production'; - return pino({ - level: process.env.LOG_LEVEL ?? 'info', - ...(usePretty - ? { transport: { target: 'pino-pretty', options: { translateTime: 'SYS:standard', ignore: 'pid,hostname' } } } - : {}), - }); + const usePretty = process.env.NODE_ENV !== 'production'; + return pino({ + level: process.env.LOG_LEVEL ?? 'info', + ...(usePretty + ? { transport: { target: 'pino-pretty', options: { translateTime: 'SYS:standard', ignore: 'pid,hostname' } } } + : {}), + }); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 04b9554..f56d878 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -5,8 +5,8 @@ import { bootstrap } from './bootstrap.js'; const invocationCwd = process.env.INIT_CWD ?? process.cwd(); bootstrap(process.argv.slice(2), invocationCwd).catch((err: unknown) => { - // 起動失敗(config 未指定/不正など)はロガー確立前のことがあるため console に出して終了する。 - const message = err instanceof Error ? (err.stack ?? err.message) : String(err); - process.stderr.write(`failed to start sensitive-detector: ${message}\n`); - process.exit(1); + // 起動失敗(config 未指定/不正など)はロガー確立前のことがあるため console に出して終了する。 + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + process.stderr.write(`failed to start sensitive-detector: ${message}\n`); + process.exit(1); }); diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index 0291840..6a3b80a 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -4,11 +4,11 @@ import { fail } from '../lib/responses.js'; import type { AppDeps, AppEnv } from '../types.js'; function parseBearer(header: string | undefined): string | undefined { - if (!header) { - return undefined; - } - const match = /^Bearer\s+(.+)$/i.exec(header); - return match?.[1]; + if (!header) { + return undefined; + } + const match = /^Bearer\s+(.+)$/i.exec(header); + return match?.[1]; } /** @@ -16,9 +16,9 @@ function parseBearer(header: string | undefined): string | undefined { * 長さ不一致での早期 return を無くし、トークン長がタイミングから漏れるのを防ぐ。 */ function safeEqual(a: string, b: string): boolean { - const ah = createHash('sha256').update(a, 'utf8').digest(); - const bh = createHash('sha256').update(b, 'utf8').digest(); - return timingSafeEqual(ah, bh); + const ah = createHash('sha256').update(a, 'utf8').digest(); + const bh = createHash('sha256').update(b, 'utf8').digest(); + return timingSafeEqual(ah, bh); } /** @@ -26,15 +26,15 @@ function safeEqual(a: string, b: string): boolean { * 欠落・不一致はどちらも AUTHENTICATION_REQUIRED(401)。ボディ読み取りより前に実行する。 */ export function auth(deps: AppDeps) { - const apiKey = deps.config.apiKey; - return createMiddleware(async (c, next) => { - if (apiKey === undefined) { - return next(); - } - const token = parseBearer(c.req.header('authorization')); - if (token === undefined || !safeEqual(token, apiKey)) { - return fail(c, 'AUTHENTICATION_REQUIRED', 'missing or invalid bearer token'); - } - return next(); - }); + const apiKey = deps.config.apiKey; + return createMiddleware(async (c, next) => { + if (apiKey === undefined) { + return next(); + } + const token = parseBearer(c.req.header('authorization')); + if (token === undefined || !safeEqual(token, apiKey)) { + return fail(c, 'AUTHENTICATION_REQUIRED', 'missing or invalid bearer token'); + } + return next(); + }); } diff --git a/apps/server/src/middleware/request-context.ts b/apps/server/src/middleware/request-context.ts index 8c4a25f..4a74eb0 100644 --- a/apps/server/src/middleware/request-context.ts +++ b/apps/server/src/middleware/request-context.ts @@ -11,10 +11,10 @@ const VALID_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/; * サイドカーとして本体側ログと突合できるよう、上流の ID をそのまま引き継ぐ。 */ function resolveRequestId(headerValue: string | undefined): string { - if (headerValue !== undefined && VALID_REQUEST_ID.test(headerValue)) { - return headerValue; - } - return randomUUID(); + if (headerValue !== undefined && VALID_REQUEST_ID.test(headerValue)) { + return headerValue; + } + return randomUUID(); } /** @@ -23,20 +23,22 @@ function resolveRequestId(headerValue: string | undefined): string { * 推論のタイムアウトは core の detectImage が内部で管理するため、ここでは扱わない。 */ export function requestContext(deps: AppDeps) { - return createMiddleware(async (c, next) => { - const requestId = resolveRequestId(c.req.header('x-request-id')); - c.header('x-request-id', requestId); - const clientIp = c.env?.incoming?.socket?.remoteAddress ?? 'unknown'; - const logger = deps.logger.child({ requestId, clientIp, method: c.req.method, path: c.req.path }); + return createMiddleware(async (c, next) => { + const requestId = resolveRequestId(c.req.header('x-request-id')); + c.header('x-request-id', requestId); + // 型上は HttpBindings だが、app.request()(テスト等)経由では c.env が undefined になるため optional chain が必要。 + // oxlint-disable-next-line typescript/no-unnecessary-condition + const clientIp = c.env?.incoming?.socket?.remoteAddress ?? 'unknown'; + const logger = deps.logger.child({ requestId, clientIp, method: c.req.method, path: c.req.path }); - c.set('requestId', requestId); - c.set('clientIp', clientIp); - c.set('logger', logger); + c.set('requestId', requestId); + c.set('clientIp', clientIp); + c.set('logger', logger); - logger.info('request received'); - const start = performance.now(); - await next(); - const durationMs = Math.round(performance.now() - start); - logger.info({ status: c.res.status, durationMs }, 'request completed'); - }); + logger.info('request received'); + const start = performance.now(); + await next(); + const durationMs = Math.round(performance.now() - start); + logger.info({ status: c.res.status, durationMs }, 'request completed'); + }); } diff --git a/apps/server/src/routes/detect-images.ts b/apps/server/src/routes/detect-images.ts index ba8b911..8545aa6 100644 --- a/apps/server/src/routes/detect-images.ts +++ b/apps/server/src/routes/detect-images.ts @@ -7,7 +7,7 @@ import type { AppDeps, AppEnv } from '../types.js'; const ACCEPTED_CONTENT_TYPES = new Set(['image/png']); function parseContentType(value: string): string { - return value.split(';')[0]?.trim().toLowerCase() ?? ''; + return value.split(';')[0]?.trim().toLowerCase() ?? ''; } /** @@ -16,69 +16,69 @@ function parseContentType(value: string): string { * dimensions 読取失敗 422 → dimensions 上限超過 413 → 推論。 */ async function classifyPart(value: File | string, deps: AppDeps): Promise { - if (!(value instanceof File)) { - return { success: false, error: { code: 'INVALID_REQUEST', message: 'part is not a file' } }; - } - - const partContentType = parseContentType(value.type); - if (!ACCEPTED_CONTENT_TYPES.has(partContentType)) { - return { - success: false, - error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: `unsupported content-type: ${partContentType || '(none)'}` }, - }; - } - - if (value.size === 0) { - return { success: false, error: { code: 'INVALID_REQUEST', message: 'part body is empty' } }; - } - - if (value.size > deps.config.maxBinarySize) { - return { - success: false, - error: { code: 'REQUEST_TOO_LARGE', message: `part exceeds ${deps.config.maxBinarySize} bytes` }, - }; - } - - let buffer: Buffer; - try { - buffer = Buffer.from(await value.arrayBuffer()); - } catch { - // パーツ本体の読み取り失敗も throw せず per-part エラーにする(部分成功不変条件: 全体は 200 のまま)。 - return { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'failed to read part body' } }; - } - - const dimensions = readImageDimensions(buffer); - if (dimensions === undefined) { - return { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'failed to read image dimensions' } }; - } - - if ( - exceedsImageLimits(dimensions, { - maxImageWidth: deps.config.maxImageWidth, - maxImageHeight: deps.config.maxImageHeight, - maxImagePixels: deps.config.maxImagePixels, - }) - ) { - return { - success: false, - error: { - code: 'REQUEST_TOO_LARGE', - message: `image dimensions ${dimensions.width}x${dimensions.height} exceed configured limits`, - }, - }; - } - - const detected = await detectImage(buffer, { - classifier: deps.classifier, - semaphore: deps.semaphore, - requestTimeoutMs: deps.config.requestTimeoutMs, - health: deps.health, - }); - - if (detected.success) { - return { success: true, predictions: detected.result.predictions }; - } - return { success: false, error: { code: detected.error.code, message: detected.error.message } }; + if (!(value instanceof File)) { + return { success: false, error: { code: 'INVALID_REQUEST', message: 'part is not a file' } }; + } + + const partContentType = parseContentType(value.type); + if (!ACCEPTED_CONTENT_TYPES.has(partContentType)) { + return { + success: false, + error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: `unsupported content-type: ${partContentType || '(none)'}` }, + }; + } + + if (value.size === 0) { + return { success: false, error: { code: 'INVALID_REQUEST', message: 'part body is empty' } }; + } + + if (value.size > deps.config.maxBinarySize) { + return { + success: false, + error: { code: 'REQUEST_TOO_LARGE', message: `part exceeds ${deps.config.maxBinarySize} bytes` }, + }; + } + + let buffer: Buffer; + try { + buffer = Buffer.from(await value.arrayBuffer()); + } catch { + // パーツ本体の読み取り失敗も throw せず per-part エラーにする(部分成功不変条件: 全体は 200 のまま)。 + return { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'failed to read part body' } }; + } + + const dimensions = readImageDimensions(buffer); + if (dimensions === undefined) { + return { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'failed to read image dimensions' } }; + } + + if ( + exceedsImageLimits(dimensions, { + maxImageWidth: deps.config.maxImageWidth, + maxImageHeight: deps.config.maxImageHeight, + maxImagePixels: deps.config.maxImagePixels, + }) + ) { + return { + success: false, + error: { + code: 'REQUEST_TOO_LARGE', + message: `image dimensions ${dimensions.width}x${dimensions.height} exceed configured limits`, + }, + }; + } + + const detected = await detectImage(buffer, { + classifier: deps.classifier, + semaphore: deps.semaphore, + requestTimeoutMs: deps.config.requestTimeoutMs, + health: deps.health, + }); + + if (detected.success) { + return { success: true, predictions: detected.result.predictions }; + } + return { success: false, error: { code: detected.error.code, message: detected.error.message } }; } /** @@ -87,81 +87,81 @@ async function classifyPart(value: File | string, deps: AppDeps): Promise) => { - const contentType = c.req.header('content-type') ?? ''; - // media type のみで厳密一致(`multipart/form-data; boundary=...` は許可、`multipart/form-dataxxx` は拒否)。 - if (parseContentType(contentType) !== 'multipart/form-data') { - return c.json( - { - success: false as const, - error: { code: 'UNSUPPORTED_MEDIA_TYPE' as const, message: 'content-type must be multipart/form-data' }, - }, - 415, - ); - } - - let formData: FormData; - try { - formData = await c.req.formData(); - } catch { - return c.json( - { - success: false as const, - error: { code: 'INVALID_REQUEST' as const, message: 'failed to parse multipart body' }, - }, - 400, - ); - } - - // 注意: maxParts はここ(multipart 全パース後)でしか効かない。パース時のメモリ/CPU を抑える - // 一次防御は bodyLimit(maxBodySize) が担う。多数の極小パーツは maxBodySize の範囲内で生成され得るため、 - // maxBodySize は maxParts×maxBinarySize + 余白に絞るのが望ましい(不整合は起動時に warn する)。 - const entries = [...formData.entries()]; - if (entries.length === 0) { - return c.json( - { success: false as const, error: { code: 'INVALID_REQUEST' as const, message: 'no parts in multipart body' } }, - 400, - ); - } - - if (entries.length > deps.config.maxParts) { - return c.json( - { - success: false as const, - error: { - code: 'REQUEST_TOO_LARGE' as const, - message: `too many parts: ${entries.length} exceeds limit of ${deps.config.maxParts}`, - }, - }, - 413, - ); - } - - const logger = c.get('logger'); - // 同時に実体化されるバッファ本数を maxConcurrentJobs 相当に抑える(推論は core の semaphore が別途律速)。 - // 結果は入力パーツ順で返るため、results[i] とリクエストパーツ i の対応は保たれる。 - const results = await mapWithConcurrency(entries, deps.config.maxConcurrentJobs, async ([, value], index) => { - let result: BatchItemResult; - try { - result = await classifyPart(value, deps); - } catch (err) { - // 不変条件防衛: classifyPart は本来 total(throw しない)だが、将来の改変でパーツ処理が想定外に - // throw すると mapWithConcurrency の fail-fast 経由でバッチ全体が 500 になり、「パーツ単位の失敗で - // 全体を 4xx/5xx にしない(部分成功は常に 200)」不変条件が破れる。ここで per-part に畳んで防ぐ。 - logger.error({ index, err }, 'unexpected error while classifying part'); - result = { - success: false, - error: { code: 'DETECTION_FAILED', message: 'internal error while processing part' }, - }; - } - if (!result.success) { - // パーツ失敗は全体 200 のまま results に格納するが、失敗率の急増を運用ログから追えるよう記録する。 - // index はリクエストパーツ順(=応答 results の順)と一致する。 - logger.warn({ index, code: result.error.code }, 'detect-images part failed'); - } - return result; - }); - - return c.json({ success: true as const, result: { results } }, 200); - }; + return async (c: Context) => { + const contentType = c.req.header('content-type') ?? ''; + // media type のみで厳密一致(`multipart/form-data; boundary=...` は許可、`multipart/form-dataxxx` は拒否)。 + if (parseContentType(contentType) !== 'multipart/form-data') { + return c.json( + { + success: false as const, + error: { code: 'UNSUPPORTED_MEDIA_TYPE' as const, message: 'content-type must be multipart/form-data' }, + }, + 415, + ); + } + + let formData: FormData; + try { + formData = await c.req.formData(); + } catch { + return c.json( + { + success: false as const, + error: { code: 'INVALID_REQUEST' as const, message: 'failed to parse multipart body' }, + }, + 400, + ); + } + + // 注意: maxParts はここ(multipart 全パース後)でしか効かない。パース時のメモリ/CPU を抑える + // 一次防御は bodyLimit(maxBodySize) が担う。多数の極小パーツは maxBodySize の範囲内で生成され得るため、 + // maxBodySize は maxParts×maxBinarySize + 余白に絞るのが望ましい(不整合は起動時に warn する)。 + const entries = [...formData.entries()]; + if (entries.length === 0) { + return c.json( + { success: false as const, error: { code: 'INVALID_REQUEST' as const, message: 'no parts in multipart body' } }, + 400, + ); + } + + if (entries.length > deps.config.maxParts) { + return c.json( + { + success: false as const, + error: { + code: 'REQUEST_TOO_LARGE' as const, + message: `too many parts: ${entries.length} exceeds limit of ${deps.config.maxParts}`, + }, + }, + 413, + ); + } + + const logger = c.get('logger'); + // 同時に実体化されるバッファ本数を maxConcurrentJobs 相当に抑える(推論は core の semaphore が別途律速)。 + // 結果は入力パーツ順で返るため、results[i] とリクエストパーツ i の対応は保たれる。 + const results = await mapWithConcurrency(entries, deps.config.maxConcurrentJobs, async ([, value], index) => { + let result: BatchItemResult; + try { + result = await classifyPart(value, deps); + } catch (err) { + // 不変条件防衛: classifyPart は本来 total(throw しない)だが、将来の改変でパーツ処理が想定外に + // throw すると mapWithConcurrency の fail-fast 経由でバッチ全体が 500 になり、「パーツ単位の失敗で + // 全体を 4xx/5xx にしない(部分成功は常に 200)」不変条件が破れる。ここで per-part に畳んで防ぐ。 + logger.error({ index, err }, 'unexpected error while classifying part'); + result = { + success: false, + error: { code: 'DETECTION_FAILED', message: 'internal error while processing part' }, + }; + } + if (!result.success) { + // パーツ失敗は全体 200 のまま results に格納するが、失敗率の急増を運用ログから追えるよう記録する。 + // index はリクエストパーツ順(=応答 results の順)と一致する。 + logger.warn({ index, code: result.error.code }, 'detect-images part failed'); + } + return result; + }); + + return c.json({ success: true as const, result: { results } }, 200); + }; } diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 3d24f71..ef1c857 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -4,20 +4,20 @@ import type { Logger } from 'pino'; /** createApp / 各ミドルウェアが共有する依存。 */ export type AppDeps = { - config: ResolvedConfig; - classifier: Classifier; - semaphore: Semaphore; - health: InferenceHealth; - logger: Logger; + config: ResolvedConfig; + classifier: Classifier; + semaphore: Semaphore; + health: InferenceHealth; + logger: Logger; }; export type AppVariables = { - requestId: string; - clientIp: string; - logger: Logger; + requestId: string; + clientIp: string; + logger: Logger; }; export type AppEnv = { - Bindings: HttpBindings; - Variables: AppVariables; + Bindings: HttpBindings; + Variables: AppVariables; }; diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts index 63ee96a..75b20f9 100644 --- a/apps/server/test/app.test.ts +++ b/apps/server/test/app.test.ts @@ -7,259 +7,259 @@ const png = encodeRgbPng(8, 8, [119, 119, 119]); const pngPart = { data: png, contentType: 'image/png' }; describe('POST /v1/detect-images', () => { - it('returns 200 with results array for a single image', async () => { - const app = buildTestApp(); - const res = await postImages(app, [pngPart]); - expect(res.status).toBe(200); - const body = (await res.json()) as { success: boolean; result: { results: unknown[] } }; - expect(body.success).toBe(true); - expect(body.result.results).toHaveLength(1); - const results = body.result.results as { success: boolean; predictions: unknown[] }[]; - expect(results[0]?.success).toBe(true); - }); + it('returns 200 with results array for a single image', async () => { + const app = buildTestApp(); + const res = await postImages(app, [pngPart]); + expect(res.status).toBe(200); + const body = (await res.json()) as { success: boolean; result: { results: unknown[] } }; + expect(body.success).toBe(true); + expect(body.result.results).toHaveLength(1); + const results = body.result.results as { success: boolean; predictions: unknown[] }[]; + expect(results[0]?.success).toBe(true); + }); - it('returns 200 with results array for multiple images', async () => { - const app = buildTestApp(); - const res = await postImages(app, [pngPart, pngPart]); - expect(res.status).toBe(200); - const body = (await res.json()) as { result: { results: unknown[] } }; - expect(body.result.results).toHaveLength(2); - }); + it('returns 200 with results array for multiple images', async () => { + const app = buildTestApp(); + const res = await postImages(app, [pngPart, pngPart]); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { results: unknown[] } }; + expect(body.result.results).toHaveLength(2); + }); - it('returns 415 when content-type is not multipart/form-data', async () => { - const app = buildTestApp(); - const res = await app.request('/v1/detect-images', { - method: 'POST', - headers: { 'content-type': 'image/png' }, - body: png, - }); - expect(res.status).toBe(415); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('UNSUPPORTED_MEDIA_TYPE'); - }); + it('returns 415 when content-type is not multipart/form-data', async () => { + const app = buildTestApp(); + const res = await app.request('/v1/detect-images', { + method: 'POST', + headers: { 'content-type': 'image/png' }, + body: png, + }); + expect(res.status).toBe(415); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); - it('returns 415 for a content-type that only prefixes multipart/form-data', async () => { - const app = buildTestApp(); - const res = await app.request('/v1/detect-images', { - method: 'POST', - headers: { 'content-type': 'multipart/form-dataxxx' }, - body: png, - }); - expect(res.status).toBe(415); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('UNSUPPORTED_MEDIA_TYPE'); - }); + it('returns 415 for a content-type that only prefixes multipart/form-data', async () => { + const app = buildTestApp(); + const res = await app.request('/v1/detect-images', { + method: 'POST', + headers: { 'content-type': 'multipart/form-dataxxx' }, + body: png, + }); + expect(res.status).toBe(415); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); - it('returns 400 when no parts are provided', async () => { - const app = buildTestApp(); - const res = await app.request('/v1/detect-images', { method: 'POST', body: new FormData() }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('INVALID_REQUEST'); - }); + it('returns 400 when no parts are provided', async () => { + const app = buildTestApp(); + const res = await app.request('/v1/detect-images', { method: 'POST', body: new FormData() }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('INVALID_REQUEST'); + }); - it('returns partial success with UNSUPPORTED_MEDIA_TYPE for bad content-type part', async () => { - const app = buildTestApp(); - const res = await postImages(app, [pngPart, { data: png, contentType: 'text/plain' }]); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { results: { success: boolean; error?: { code: string } }[] }; - }; - expect(body.result.results[0]?.success).toBe(true); - expect(body.result.results[1]?.success).toBe(false); - expect(body.result.results[1]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE'); - }); + it('returns partial success with UNSUPPORTED_MEDIA_TYPE for bad content-type part', async () => { + const app = buildTestApp(); + const res = await postImages(app, [pngPart, { data: png, contentType: 'text/plain' }]); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { results: { success: boolean; error?: { code: string } }[] }; + }; + expect(body.result.results[0]?.success).toBe(true); + expect(body.result.results[1]?.success).toBe(false); + expect(body.result.results[1]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); - it('preserves part order across the concurrency limit (parts > maxConcurrentJobs)', async () => { - // maxConcurrentJobs=2 < 5 パーツ。index 2 だけ非対応 Content-Type。 - // mapWithConcurrency 経由でも results[i] とパーツ i の対応(順序)が保たれることを検証する。 - const app = buildTestApp({ config: { maxConcurrentJobs: 2 } }); - const parts = [pngPart, pngPart, { data: png, contentType: 'text/plain' }, pngPart, pngPart]; - const res = await postImages(app, parts); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { results: { success: boolean; error?: { code: string } }[] }; - }; - const results = body.result.results; - expect(results).toHaveLength(5); - expect(results.map((r) => r.success)).toEqual([true, true, false, true, true]); - expect(results[2]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE'); - }); + it('preserves part order across the concurrency limit (parts > maxConcurrentJobs)', async () => { + // maxConcurrentJobs=2 < 5 パーツ。index 2 だけ非対応 Content-Type。 + // mapWithConcurrency 経由でも results[i] とパーツ i の対応(順序)が保たれることを検証する。 + const app = buildTestApp({ config: { maxConcurrentJobs: 2 } }); + const parts = [pngPart, pngPart, { data: png, contentType: 'text/plain' }, pngPart, pngPart]; + const res = await postImages(app, parts); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { results: { success: boolean; error?: { code: string } }[] }; + }; + const results = body.result.results; + expect(results).toHaveLength(5); + expect(results.map((r) => r.success)).toEqual([true, true, false, true, true]); + expect(results[2]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); - it('rejects image/jpeg, image/gif, image/bmp with UNSUPPORTED_MEDIA_TYPE', async () => { - const app = buildTestApp(); - for (const contentType of ['image/jpeg', 'image/gif', 'image/bmp']) { - const res = await postImages(app, [{ data: png, contentType }]); - expect(res.status).toBe(200); - const body = (await res.json()) as { result: { results: { success: boolean; error?: { code: string } }[] } }; - expect(body.result.results[0]?.success).toBe(false); - expect(body.result.results[0]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE'); - } - }); + it('rejects image/jpeg, image/gif, image/bmp with UNSUPPORTED_MEDIA_TYPE', async () => { + const app = buildTestApp(); + for (const contentType of ['image/jpeg', 'image/gif', 'image/bmp']) { + const res = await postImages(app, [{ data: png, contentType }]); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { results: { success: boolean; error?: { code: string } }[] } }; + expect(body.result.results[0]?.success).toBe(false); + expect(body.result.results[0]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE'); + } + }); - it('propagates classifier error codes into per-part results', async () => { - const cases = [ - { code: 'IMAGE_DECODE_FAILED' }, - { code: 'MODEL_UNAVAILABLE' }, - { code: 'DETECTION_FAILED' }, - ] as const; - for (const { code } of cases) { - const app = buildTestApp({ classifier: mockClassifier({ ok: false, code }) }); - const res = await postImages(app, [pngPart]); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { results: { success: boolean; error?: { code: string } }[] }; - }; - expect(body.result.results[0]?.success).toBe(false); - expect(body.result.results[0]?.error?.code).toBe(code); - } - }); + it('propagates classifier error codes into per-part results', async () => { + const cases = [ + { code: 'IMAGE_DECODE_FAILED' }, + { code: 'MODEL_UNAVAILABLE' }, + { code: 'DETECTION_FAILED' }, + ] as const; + for (const { code } of cases) { + const app = buildTestApp({ classifier: mockClassifier({ ok: false, code }) }); + const res = await postImages(app, [pngPart]); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { results: { success: boolean; error?: { code: string } }[] }; + }; + expect(body.result.results[0]?.success).toBe(false); + expect(body.result.results[0]?.error?.code).toBe(code); + } + }); - it('returns per-part REQUEST_TOO_LARGE when a part exceeds maxBinarySize', async () => { - const app = buildTestApp({ config: { maxBinarySize: 4 } }); - const res = await postImages(app, [{ data: new Uint8Array(5).fill(1), contentType: 'image/png' }]); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { results: { success: boolean; error?: { code: string } }[] }; - }; - expect(body.result.results[0]?.success).toBe(false); - expect(body.result.results[0]?.error?.code).toBe('REQUEST_TOO_LARGE'); - }); + it('returns per-part REQUEST_TOO_LARGE when a part exceeds maxBinarySize', async () => { + const app = buildTestApp({ config: { maxBinarySize: 4 } }); + const res = await postImages(app, [{ data: new Uint8Array(5).fill(1), contentType: 'image/png' }]); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { results: { success: boolean; error?: { code: string } }[] }; + }; + expect(body.result.results[0]?.success).toBe(false); + expect(body.result.results[0]?.error?.code).toBe('REQUEST_TOO_LARGE'); + }); - it('accepts a part up to the size limit', async () => { - const app = buildTestApp({ config: { maxBinarySize: png.length } }); - const res = await postImages(app, [pngPart]); - expect(res.status).toBe(200); - const body = (await res.json()) as { result: { results: { success: boolean }[] } }; - expect(body.result.results[0]?.success).toBe(true); - }); + it('accepts a part up to the size limit', async () => { + const app = buildTestApp({ config: { maxBinarySize: png.length } }); + const res = await postImages(app, [pngPart]); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { results: { success: boolean }[] } }; + expect(body.result.results[0]?.success).toBe(true); + }); - it('rejects a part when decoded image dimensions exceed configured limits', async () => { - const app = buildTestApp({ config: { maxImageWidth: 16, maxImageHeight: 16, maxImagePixels: 256 } }); - const res = await postImages(app, [{ data: encodeRgbPng(17, 16, [119, 119, 119]), contentType: 'image/png' }]); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { results: { success: boolean; error?: { code: string } }[] }; - }; - expect(body.result.results[0]?.success).toBe(false); - expect(body.result.results[0]?.error?.code).toBe('REQUEST_TOO_LARGE'); - }); + it('rejects a part when decoded image dimensions exceed configured limits', async () => { + const app = buildTestApp({ config: { maxImageWidth: 16, maxImageHeight: 16, maxImagePixels: 256 } }); + const res = await postImages(app, [{ data: encodeRgbPng(17, 16, [119, 119, 119]), contentType: 'image/png' }]); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { results: { success: boolean; error?: { code: string } }[] }; + }; + expect(body.result.results[0]?.success).toBe(false); + expect(body.result.results[0]?.error?.code).toBe('REQUEST_TOO_LARGE'); + }); }); describe('authentication', () => { - const apiKey = 'super-secret-token'; - const withAuth = () => buildTestApp({ config: { apiKey } }); + const apiKey = 'super-secret-token'; + const withAuth = () => buildTestApp({ config: { apiKey } }); - it('passes through when no apiKey is configured', async () => { - const res = await postImages(buildTestApp(), [pngPart]); - expect(res.status).toBe(200); - }); + it('passes through when no apiKey is configured', async () => { + const res = await postImages(buildTestApp(), [pngPart]); + expect(res.status).toBe(200); + }); - it('rejects a missing Authorization header with 401', async () => { - const res = await postImages(withAuth(), [pngPart]); - expect(res.status).toBe(401); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('AUTHENTICATION_REQUIRED'); - }); + it('rejects a missing Authorization header with 401', async () => { + const res = await postImages(withAuth(), [pngPart]); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('AUTHENTICATION_REQUIRED'); + }); - it('rejects a wrong token with 401', async () => { - const res = await postImages(withAuth(), [pngPart], { authorization: 'Bearer wrong-token' }); - expect(res.status).toBe(401); - }); + it('rejects a wrong token with 401', async () => { + const res = await postImages(withAuth(), [pngPart], { authorization: 'Bearer wrong-token' }); + expect(res.status).toBe(401); + }); - it('accepts the correct bearer token', async () => { - const res = await postImages(withAuth(), [pngPart], { authorization: `Bearer ${apiKey}` }); - expect(res.status).toBe(200); - }); + it('accepts the correct bearer token', async () => { + const res = await postImages(withAuth(), [pngPart], { authorization: `Bearer ${apiKey}` }); + expect(res.status).toBe(200); + }); }); describe('GET /health', () => { - it('returns 200 ok when the model is available', async () => { - const res = await buildTestApp().request('/health'); - expect(res.status).toBe(200); - const body = (await res.json()) as { status: string }; - expect(body.status).toBe('ok'); - }); + it('returns 200 ok when the model is available', async () => { + const res = await buildTestApp().request('/health'); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe('ok'); + }); - it('returns 503 unavailable with reason model_unavailable when the model is not loaded', async () => { - const unavailable = mockClassifier({ ok: false, code: 'MODEL_UNAVAILABLE' }, false); - const res = await buildTestApp({ classifier: unavailable }).request('/health'); - expect(res.status).toBe(503); - const body = (await res.json()) as { status: string; reason?: string }; - expect(body.status).toBe('unavailable'); - expect(body.reason).toBe('model_unavailable'); - }); + it('returns 503 unavailable with reason model_unavailable when the model is not loaded', async () => { + const unavailable = mockClassifier({ ok: false, code: 'MODEL_UNAVAILABLE' }, false); + const res = await buildTestApp({ classifier: unavailable }).request('/health'); + expect(res.status).toBe(503); + const body = (await res.json()) as { status: string; reason?: string }; + expect(body.status).toBe('unavailable'); + expect(body.reason).toBe('model_unavailable'); + }); - it('returns 503 unavailable with reason inference_saturated when all slots are stuck', async () => { - const health = new InferenceHealth(1); - health.markStuck(); // 容量 1 が hung で詰まった状態を再現。 - const res = await buildTestApp({ health }).request('/health'); - expect(res.status).toBe(503); - const body = (await res.json()) as { status: string; reason?: string }; - expect(body.status).toBe('unavailable'); - expect(body.reason).toBe('inference_saturated'); - }); + it('returns 503 unavailable with reason inference_saturated when all slots are stuck', async () => { + const health = new InferenceHealth(1); + health.markStuck(); // 容量 1 が hung で詰まった状態を再現。 + const res = await buildTestApp({ health }).request('/health'); + expect(res.status).toBe(503); + const body = (await res.json()) as { status: string; reason?: string }; + expect(body.status).toBe('unavailable'); + expect(body.reason).toBe('inference_saturated'); + }); - it('does not require authentication even when apiKey is configured', async () => { - const res = await buildTestApp({ config: { apiKey: 'super-secret-token' } }).request('/health'); - expect(res.status).toBe(200); - }); + it('does not require authentication even when apiKey is configured', async () => { + const res = await buildTestApp({ config: { apiKey: 'super-secret-token' } }).request('/health'); + expect(res.status).toBe(200); + }); }); describe('unknown routes', () => { - it('returns 400 INVALID_REQUEST for unmatched routes', async () => { - const res = await buildTestApp().request('/nope'); - expect(res.status).toBe(400); - }); + it('returns 400 INVALID_REQUEST for unmatched routes', async () => { + const res = await buildTestApp().request('/nope'); + expect(res.status).toBe(400); + }); }); describe('request correlation id', () => { - it('echoes a valid inbound x-request-id back on the response', async () => { - const res = await postImages(buildTestApp(), [pngPart], { 'x-request-id': 'misskey-abc.123_DEF' }); - expect(res.status).toBe(200); - expect(res.headers.get('x-request-id')).toBe('misskey-abc.123_DEF'); - }); + it('echoes a valid inbound x-request-id back on the response', async () => { + const res = await postImages(buildTestApp(), [pngPart], { 'x-request-id': 'misskey-abc.123_DEF' }); + expect(res.status).toBe(200); + expect(res.headers.get('x-request-id')).toBe('misskey-abc.123_DEF'); + }); - it('generates a request id when the inbound header is missing', async () => { - const res = await postImages(buildTestApp(), [pngPart]); - expect(res.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/); - }); + it('generates a request id when the inbound header is missing', async () => { + const res = await postImages(buildTestApp(), [pngPart]); + expect(res.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/); + }); - it('ignores a malformed inbound x-request-id and generates its own', async () => { - const res = await postImages(buildTestApp(), [pngPart], { 'x-request-id': 'bad id with spaces!' }); - const echoed = res.headers.get('x-request-id'); - expect(echoed).not.toBe('bad id with spaces!'); - expect(echoed).toMatch(/^[0-9a-f-]{36}$/); - }); + it('ignores a malformed inbound x-request-id and generates its own', async () => { + const res = await postImages(buildTestApp(), [pngPart], { 'x-request-id': 'bad id with spaces!' }); + const echoed = res.headers.get('x-request-id'); + expect(echoed).not.toBe('bad id with spaces!'); + expect(echoed).toMatch(/^[0-9a-f-]{36}$/); + }); - it('stamps x-request-id on error responses too', async () => { - const res = await buildTestApp().request('/nope', { headers: { 'x-request-id': 'corr-1' } }); - expect(res.status).toBe(400); - expect(res.headers.get('x-request-id')).toBe('corr-1'); - }); + it('stamps x-request-id on error responses too', async () => { + const res = await buildTestApp().request('/nope', { headers: { 'x-request-id': 'corr-1' } }); + expect(res.status).toBe(400); + expect(res.headers.get('x-request-id')).toBe('corr-1'); + }); }); describe('request size limits', () => { - it('returns 413 when parts exceed maxParts', async () => { - const app = buildTestApp({ config: { maxParts: 2 } }); - const res = await postImages(app, [pngPart, pngPart, pngPart]); - expect(res.status).toBe(413); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('REQUEST_TOO_LARGE'); - }); + it('returns 413 when parts exceed maxParts', async () => { + const app = buildTestApp({ config: { maxParts: 2 } }); + const res = await postImages(app, [pngPart, pngPart, pngPart]); + expect(res.status).toBe(413); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('REQUEST_TOO_LARGE'); + }); - it('accepts exactly maxParts parts', async () => { - const app = buildTestApp({ config: { maxParts: 2 } }); - const res = await postImages(app, [pngPart, pngPart]); - expect(res.status).toBe(200); - }); + it('accepts exactly maxParts parts', async () => { + const app = buildTestApp({ config: { maxParts: 2 } }); + const res = await postImages(app, [pngPart, pngPart]); + expect(res.status).toBe(200); + }); - it('returns 413 when body exceeds maxBodySize', async () => { - // maxBodySize を小さくして multipart boundary 込みで確実に超えさせる。 - const app = buildTestApp({ config: { maxBodySize: 10 } }); - const res = await postImages(app, [pngPart]); - expect(res.status).toBe(413); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe('REQUEST_TOO_LARGE'); - }); + it('returns 413 when body exceeds maxBodySize', async () => { + // maxBodySize を小さくして multipart boundary 込みで確実に超えさせる。 + const app = buildTestApp({ config: { maxBodySize: 10 } }); + const res = await postImages(app, [pngPart]); + expect(res.status).toBe(413); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe('REQUEST_TOO_LARGE'); + }); }); diff --git a/apps/server/test/concurrency.test.ts b/apps/server/test/concurrency.test.ts index f2f6ab7..fee9c51 100644 --- a/apps/server/test/concurrency.test.ts +++ b/apps/server/test/concurrency.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from 'vitest'; import { mapWithConcurrency } from '../src/lib/concurrency.js'; describe('mapWithConcurrency', () => { - it('returns results in input order regardless of completion order', async () => { - const out = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => { - // 後ろの要素ほど早く終わらせ、完了順と入力順を意図的にずらす。 - await new Promise((resolve) => setTimeout(resolve, (6 - n) * 2)); - return n * 10; - }); - expect(out).toEqual([10, 20, 30, 40, 50]); - }); + it('returns results in input order regardless of completion order', async () => { + const out = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => { + // 後ろの要素ほど早く終わらせ、完了順と入力順を意図的にずらす。 + await new Promise((resolve) => setTimeout(resolve, (6 - n) * 2)); + return n * 10; + }); + expect(out).toEqual([10, 20, 30, 40, 50]); + }); - it('never runs more than `limit` tasks at once', async () => { - let active = 0; - let peak = 0; - await mapWithConcurrency( - Array.from({ length: 10 }, (_, i) => i), - 3, - async () => { - active += 1; - peak = Math.max(peak, active); - await new Promise((resolve) => setTimeout(resolve, 3)); - active -= 1; - }, - ); - expect(peak).toBeLessThanOrEqual(3); - }); + it('never runs more than `limit` tasks at once', async () => { + let active = 0; + let peak = 0; + await mapWithConcurrency( + Array.from({ length: 10 }, (_, i) => i), + 3, + async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 3)); + active -= 1; + }, + ); + expect(peak).toBeLessThanOrEqual(3); + }); - it('handles an empty input', async () => { - expect(await mapWithConcurrency([], 4, async (n) => n)).toEqual([]); - }); + it('handles an empty input', async () => { + expect(await mapWithConcurrency([], 4, async (n) => n)).toEqual([]); + }); - it('passes the original index to fn', async () => { - const out = await mapWithConcurrency(['a', 'b', 'c'], 2, async (item, index) => `${index}:${item}`); - expect(out).toEqual(['0:a', '1:b', '2:c']); - }); + it('passes the original index to fn', async () => { + const out = await mapWithConcurrency(['a', 'b', 'c'], 2, async (item, index) => `${index}:${item}`); + expect(out).toEqual(['0:a', '1:b', '2:c']); + }); - it('rejects with the first error and stops scheduling new items (fail-fast)', async () => { - const started: number[] = []; - await expect( - mapWithConcurrency( - Array.from({ length: 10 }, (_, i) => i), - 2, - async (n) => { - started.push(n); - await new Promise((resolve) => setTimeout(resolve, 1)); - if (n === 1) { - throw new Error('boom'); - } - return n; - }, - ), - ).rejects.toThrow('boom'); - // 2 並列なので 0,1 は着手するが、失敗後に 10 件すべてを着手することはない。 - expect(started.length).toBeLessThan(10); - }); + it('rejects with the first error and stops scheduling new items (fail-fast)', async () => { + const started: number[] = []; + await expect( + mapWithConcurrency( + Array.from({ length: 10 }, (_, i) => i), + 2, + async (n) => { + started.push(n); + await new Promise((resolve) => setTimeout(resolve, 1)); + if (n === 1) { + throw new Error('boom'); + } + return n; + }, + ), + ).rejects.toThrow('boom'); + // 2 並列なので 0,1 は着手するが、失敗後に 10 件すべてを着手することはない。 + expect(started.length).toBeLessThan(10); + }); }); diff --git a/apps/server/test/detect-images.integration.test.ts b/apps/server/test/detect-images.integration.test.ts index 5414395..cd12ad5 100644 --- a/apps/server/test/detect-images.integration.test.ts +++ b/apps/server/test/detect-images.integration.test.ts @@ -11,109 +11,109 @@ const API_KEY = 'integration-test-key'; const d = (await integrationEnabled()) ? describe : describe.skip; d('POST /v1/detect-images (real model, real HTTP server)', () => { - let server: ReturnType | undefined; - let baseUrl = ''; + let server: ReturnType | undefined; + let baseUrl = ''; - beforeAll(async () => { - const classifier = await createClassifier(TEST_MODEL_DIR, { logger: silentLogger }); - const config: ResolvedConfig = { - listen: { kind: 'port', port: 0, host: '127.0.0.1' }, - modelDir: TEST_MODEL_DIR, - apiKey: API_KEY, - allowUnauthenticatedTcp: false, - maxBinarySize: 1_048_576, - maxImageWidth: 299, - maxImageHeight: 299, - maxImagePixels: 299 * 299, - maxParts: 10, - maxBodySize: 12_582_912, - maxConcurrentJobs: 2, - requestTimeoutMs: 60_000, - }; - const app = createApp({ - config, - classifier, - semaphore: new Semaphore(config.maxConcurrentJobs), - health: new InferenceHealth(config.maxConcurrentJobs), - logger: silentLogger, - }); + beforeAll(async () => { + const classifier = await createClassifier(TEST_MODEL_DIR, { logger: silentLogger }); + const config: ResolvedConfig = { + listen: { kind: 'port', port: 0, host: '127.0.0.1' }, + modelDir: TEST_MODEL_DIR, + apiKey: API_KEY, + allowUnauthenticatedTcp: false, + maxBinarySize: 1_048_576, + maxImageWidth: 299, + maxImageHeight: 299, + maxImagePixels: 299 * 299, + maxParts: 10, + maxBodySize: 12_582_912, + maxConcurrentJobs: 2, + requestTimeoutMs: 60_000, + }; + const app = createApp({ + config, + classifier, + semaphore: new Semaphore(config.maxConcurrentJobs), + health: new InferenceHealth(config.maxConcurrentJobs), + logger: silentLogger, + }); - const port = await new Promise((resolve) => { - server = serve({ fetch: app.fetch, port: 0 }, (info) => resolve(info.port)); - }); - baseUrl = `http://127.0.0.1:${port}`; - }); + const port = await new Promise((resolve) => { + server = serve({ fetch: app.fetch, port: 0 }, (info) => resolve(info.port)); + }); + baseUrl = `http://127.0.0.1:${port}`; + }); - afterAll(() => { - server?.close(); - }); + afterAll(() => { + server?.close(); + }); - function buildForm(parts: { data: Uint8Array; contentType: string }[]): FormData { - const form = new FormData(); - for (const [i, part] of parts.entries()) { - form.append(`image${i}`, new File([part.data], `image${i}`, { type: part.contentType })); - } - return form; - } + function buildForm(parts: { data: Uint8Array; contentType: string }[]): FormData { + const form = new FormData(); + for (const [i, part] of parts.entries()) { + form.append(`image${i}`, new File([part.data], `image${i}`, { type: part.contentType })); + } + return form; + } - const authedPost = (form: FormData) => - fetch(`${baseUrl}/v1/detect-images`, { - method: 'POST', - headers: { authorization: `Bearer ${API_KEY}` }, - body: form, - }); + const authedPost = (form: FormData) => + fetch(`${baseUrl}/v1/detect-images`, { + method: 'POST', + headers: { authorization: `Bearer ${API_KEY}` }, + body: form, + }); - it('returns 200 with five predictions per image for two normalized PNGs', async () => { - const png = encodeRgbPng(299, 299, [119, 119, 119]); - const res = await authedPost( - buildForm([ - { data: png, contentType: 'image/png' }, - { data: png, contentType: 'image/png' }, - ]), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - success: boolean; - result: { results: { success: boolean; predictions: { className: string; probability: number }[] }[] }; - }; - expect(body.success).toBe(true); - expect(body.result.results).toHaveLength(2); - for (const r of body.result.results) { - expect(r.success).toBe(true); - expect(r.predictions).toHaveLength(5); - expect([...r.predictions.map((p) => p.className)].sort()).toEqual([ - 'Drawing', - 'Hentai', - 'Neutral', - 'Porn', - 'Sexy', - ]); - } - }); + it('returns 200 with five predictions per image for two normalized PNGs', async () => { + const png = encodeRgbPng(299, 299, [119, 119, 119]); + const res = await authedPost( + buildForm([ + { data: png, contentType: 'image/png' }, + { data: png, contentType: 'image/png' }, + ]), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + success: boolean; + result: { results: { success: boolean; predictions: { className: string; probability: number }[] }[] }; + }; + expect(body.success).toBe(true); + expect(body.result.results).toHaveLength(2); + for (const r of body.result.results) { + expect(r.success).toBe(true); + expect(r.predictions).toHaveLength(5); + expect(r.predictions.map((p) => p.className).sort()).toEqual([ + 'Drawing', + 'Hentai', + 'Neutral', + 'Porn', + 'Sexy', + ]); + } + }); - it('returns partial success when one part has corrupt image bytes', async () => { - const png = encodeRgbPng(299, 299, [119, 119, 119]); - const res = await authedPost( - buildForm([ - { data: png, contentType: 'image/png' }, - { data: Buffer.from('not an image'), contentType: 'image/png' }, - ]), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { results: { success: boolean; error?: { code: string } }[] }; - }; - expect(body.result.results[0]?.success).toBe(true); - expect(body.result.results[1]?.success).toBe(false); - expect(body.result.results[1]?.error?.code).toBe('IMAGE_DECODE_FAILED'); - }); + it('returns partial success when one part has corrupt image bytes', async () => { + const png = encodeRgbPng(299, 299, [119, 119, 119]); + const res = await authedPost( + buildForm([ + { data: png, contentType: 'image/png' }, + { data: Buffer.from('not an image'), contentType: 'image/png' }, + ]), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { results: { success: boolean; error?: { code: string } }[] }; + }; + expect(body.result.results[0]?.success).toBe(true); + expect(body.result.results[1]?.success).toBe(false); + expect(body.result.results[1]?.error?.code).toBe('IMAGE_DECODE_FAILED'); + }); - it('returns 401 without a bearer token', async () => { - const png = encodeRgbPng(8, 8, [119, 119, 119]); - const res = await fetch(`${baseUrl}/v1/detect-images`, { - method: 'POST', - body: buildForm([{ data: png, contentType: 'image/png' }]), - }); - expect(res.status).toBe(401); - }); + it('returns 401 without a bearer token', async () => { + const png = encodeRgbPng(8, 8, [119, 119, 119]); + const res = await fetch(`${baseUrl}/v1/detect-images`, { + method: 'POST', + body: buildForm([{ data: png, contentType: 'image/png' }]), + }); + expect(res.status).toBe(401); + }); }); diff --git a/apps/server/test/error-mapping.test.ts b/apps/server/test/error-mapping.test.ts index 90a5b92..ac90552 100644 --- a/apps/server/test/error-mapping.test.ts +++ b/apps/server/test/error-mapping.test.ts @@ -4,19 +4,19 @@ import { statusForCode } from '../src/lib/error-mapping.js'; // Record で網羅性をコンパイル時に強制しつつ、実行時にも全コードを照合する。 const EXPECTED: Record = { - AUTHENTICATION_REQUIRED: 401, - INVALID_REQUEST: 400, - UNSUPPORTED_MEDIA_TYPE: 415, - REQUEST_TOO_LARGE: 413, - IMAGE_DECODE_FAILED: 422, - MODEL_UNAVAILABLE: 503, - DETECTION_FAILED: 500, + AUTHENTICATION_REQUIRED: 401, + INVALID_REQUEST: 400, + UNSUPPORTED_MEDIA_TYPE: 415, + REQUEST_TOO_LARGE: 413, + IMAGE_DECODE_FAILED: 422, + MODEL_UNAVAILABLE: 503, + DETECTION_FAILED: 500, }; describe('statusForCode', () => { - it('maps every DetectErrorCode to the expected HTTP status', () => { - for (const code of Object.keys(EXPECTED) as DetectErrorCode[]) { - expect(statusForCode(code)).toBe(EXPECTED[code]); - } - }); + it('maps every DetectErrorCode to the expected HTTP status', () => { + for (const code of Object.keys(EXPECTED) as DetectErrorCode[]) { + expect(statusForCode(code)).toBe(EXPECTED[code]); + } + }); }); diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 28364b8..ee0e280 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -1,73 +1,73 @@ import { - type Classifier, - type ClassifyResult, - InferenceHealth, - type ResolvedConfig, - Semaphore, + type Classifier, + type ClassifyResult, + InferenceHealth, + type ResolvedConfig, + Semaphore, } from '@misskey-sensitive-detector/core'; import { type Logger, pino } from 'pino'; import { createApp } from '../../src/app.js'; import type { AppDeps } from '../../src/types.js'; type TestOverrides = { - config?: Partial; - classifier?: Classifier; - semaphore?: Semaphore; - health?: InferenceHealth; - logger?: Logger; + config?: Partial; + classifier?: Classifier; + semaphore?: Semaphore; + health?: InferenceHealth; + logger?: Logger; }; export const silentLogger = pino({ level: 'silent' }); export function mockClassifier(result: ClassifyResult, available = true): Classifier { - return { available, classify: () => Promise.resolve(result) }; + return { available, classify: () => Promise.resolve(result) }; } const okClassifier = mockClassifier({ - ok: true, - predictions: [ - { className: 'Neutral', probability: 0.95 }, - { className: 'Drawing', probability: 0.03 }, - { className: 'Sexy', probability: 0.01 }, - { className: 'Hentai', probability: 0.005 }, - { className: 'Porn', probability: 0.005 }, - ], + ok: true, + predictions: [ + { className: 'Neutral', probability: 0.95 }, + { className: 'Drawing', probability: 0.03 }, + { className: 'Sexy', probability: 0.01 }, + { className: 'Hentai', probability: 0.005 }, + { className: 'Porn', probability: 0.005 }, + ], }); export function buildTestApp(overrides: TestOverrides = {}) { - const config: ResolvedConfig = { - listen: { kind: 'port', port: 0, host: '127.0.0.1' }, - modelDir: '/models/', - allowUnauthenticatedTcp: true, - maxBinarySize: 1024, - maxImageWidth: 299, - maxImageHeight: 299, - maxImagePixels: 299 * 299, - maxParts: 10, - maxBodySize: 10 * 1024 * 1024, - maxConcurrentJobs: 2, - requestTimeoutMs: 1_000, - ...overrides.config, - }; - const deps: AppDeps = { - config, - classifier: overrides.classifier ?? okClassifier, - semaphore: overrides.semaphore ?? new Semaphore(config.maxConcurrentJobs), - health: overrides.health ?? new InferenceHealth(config.maxConcurrentJobs), - logger: overrides.logger ?? silentLogger, - }; - return createApp(deps); + const config: ResolvedConfig = { + listen: { kind: 'port', port: 0, host: '127.0.0.1' }, + modelDir: '/models/', + allowUnauthenticatedTcp: true, + maxBinarySize: 1024, + maxImageWidth: 299, + maxImageHeight: 299, + maxImagePixels: 299 * 299, + maxParts: 10, + maxBodySize: 10 * 1024 * 1024, + maxConcurrentJobs: 2, + requestTimeoutMs: 1_000, + ...overrides.config, + }; + const deps: AppDeps = { + config, + classifier: overrides.classifier ?? okClassifier, + semaphore: overrides.semaphore ?? new Semaphore(config.maxConcurrentJobs), + health: overrides.health ?? new InferenceHealth(config.maxConcurrentJobs), + logger: overrides.logger ?? silentLogger, + }; + return createApp(deps); } /** multipart/form-data として複数の画像パーツを POST する。 */ export function postImages( - app: ReturnType, - parts: { data: Uint8Array; contentType: string }[], - extraHeaders: Record = {}, + app: ReturnType, + parts: { data: Uint8Array; contentType: string }[], + extraHeaders: Record = {}, ) { - const form = new FormData(); - for (const [i, part] of parts.entries()) { - form.append(`image${i}`, new File([part.data], `image${i}`, { type: part.contentType })); - } - return app.request('/v1/detect-images', { method: 'POST', headers: extraHeaders, body: form }); + const form = new FormData(); + for (const [i, part] of parts.entries()) { + form.append(`image${i}`, new File([part.data], `image${i}`, { type: part.contentType })); + } + return app.request('/v1/detect-images', { method: 'POST', headers: extraHeaders, body: form }); } diff --git a/apps/server/test/image-metadata.test.ts b/apps/server/test/image-metadata.test.ts index 23ca963..0f423f3 100644 --- a/apps/server/test/image-metadata.test.ts +++ b/apps/server/test/image-metadata.test.ts @@ -9,251 +9,251 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; /** crc を 0 埋めした最小 PNG ヘッダ(parser は crc を検証しない)。寸法 0 等の異常系を組み立てるために使う。 */ function pngHeader(width: number, height: number): Buffer { - const sig = Buffer.from(PNG_SIGNATURE); - const lenAndType = Buffer.alloc(8); - lenAndType.writeUInt32BE(13, 0); // IHDR チャンク長 - lenAndType.write('IHDR', 4, 'ascii'); - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - const crc = Buffer.alloc(4); - return Buffer.concat([sig, lenAndType, ihdr, crc]); // 8 + 8 + 13 + 4 = 33 bytes + const sig = Buffer.from(PNG_SIGNATURE); + const lenAndType = Buffer.alloc(8); + lenAndType.writeUInt32BE(13, 0); // IHDR チャンク長 + lenAndType.write('IHDR', 4, 'ascii'); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + const crc = Buffer.alloc(4); + return Buffer.concat([sig, lenAndType, ihdr, crc]); // 8 + 8 + 13 + 4 = 33 bytes } /** SOI → (任意で APP0) → SOFn を持つ最小 JPEG。SOFn は 3 コンポーネント(segment length 17)。 */ function jpeg(opts: { width: number; height: number; marker?: number; withApp0?: boolean }): Buffer { - const { width, height, marker = 0xc0, withApp0 = false } = opts; - const bytes: number[] = [0xff, 0xd8]; // SOI - if (withApp0) { - // APP0(JFIF): FF E0, length 16, "JFIF\0", version, density 単位/値 — SOF まで読み飛ばされることの確認用。 - bytes.push( - 0xff, - 0xe0, - 0x00, - 0x10, - 0x4a, - 0x46, - 0x49, - 0x46, - 0x00, - 0x01, - 0x01, - 0x00, - 0x00, - 0x01, - 0x00, - 0x01, - 0x00, - 0x00, - ); - } - // SOFn: FF , length 0x0011, precision 8, height(2 BE), width(2 BE), nComp 3, 各コンポーネント 3 bytes。 - bytes.push( - 0xff, - marker, - 0x00, - 0x11, - 0x08, - (height >> 8) & 0xff, - height & 0xff, - (width >> 8) & 0xff, - width & 0xff, - 0x03, - 0x01, - 0x22, - 0x00, - 0x02, - 0x11, - 0x01, - 0x03, - 0x11, - 0x01, - ); - return Buffer.from(bytes); + const { width, height, marker = 0xc0, withApp0 = false } = opts; + const bytes: number[] = [0xff, 0xd8]; // SOI + if (withApp0) { + // APP0(JFIF): FF E0, length 16, "JFIF\0", version, density 単位/値 — SOF まで読み飛ばされることの確認用。 + bytes.push( + 0xff, + 0xe0, + 0x00, + 0x10, + 0x4a, + 0x46, + 0x49, + 0x46, + 0x00, + 0x01, + 0x01, + 0x00, + 0x00, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + ); + } + // SOFn: FF , length 0x0011, precision 8, height(2 BE), width(2 BE), nComp 3, 各コンポーネント 3 bytes。 + bytes.push( + 0xff, + marker, + 0x00, + 0x11, + 0x08, + (height >> 8) & 0xff, + height & 0xff, + (width >> 8) & 0xff, + width & 0xff, + 0x03, + 0x01, + 0x22, + 0x00, + 0x02, + 0x11, + 0x01, + 0x03, + 0x11, + 0x01, + ); + return Buffer.from(bytes); } function gif(opts: { width: number; height: number; version?: '87a' | '89a' }): Buffer { - const { width, height, version = '89a' } = opts; - const header = Buffer.from(`GIF${version}`, 'ascii'); // 6 bytes - const lsd = Buffer.alloc(7); // logical screen descriptor - lsd.writeUInt16LE(width, 0); - lsd.writeUInt16LE(height, 2); - return Buffer.concat([header, lsd]); // 13 bytes + const { width, height, version = '89a' } = opts; + const header = Buffer.from(`GIF${version}`, 'ascii'); // 6 bytes + const lsd = Buffer.alloc(7); // logical screen descriptor + lsd.writeUInt16LE(width, 0); + lsd.writeUInt16LE(height, 2); + return Buffer.concat([header, lsd]); // 13 bytes } /** 40-byte DIB(BITMAPINFOHEADER)。height は負値で top-down DIB を表現できる。 */ function bmpInfo(opts: { width: number; height: number }): Buffer { - const buf = Buffer.alloc(54); - buf.write('BM', 0, 'ascii'); - buf.writeUInt32LE(54, 2); // file size - buf.writeUInt32LE(54, 10); // pixel data offset - buf.writeUInt32LE(40, 14); // dibHeaderSize - buf.writeInt32LE(opts.width, 18); - buf.writeInt32LE(opts.height, 22); - return buf; + const buf = Buffer.alloc(54); + buf.write('BM', 0, 'ascii'); + buf.writeUInt32LE(54, 2); // file size + buf.writeUInt32LE(54, 10); // pixel data offset + buf.writeUInt32LE(40, 14); // dibHeaderSize + buf.writeInt32LE(opts.width, 18); + buf.writeInt32LE(opts.height, 22); + return buf; } /** 12-byte DIB(OS/2 BITMAPCOREHEADER)。width/height は UInt16。 */ function bmpCore(opts: { width: number; height: number }): Buffer { - const buf = Buffer.alloc(26); - buf.write('BM', 0, 'ascii'); - buf.writeUInt32LE(26, 2); - buf.writeUInt32LE(26, 10); - buf.writeUInt32LE(12, 14); // dibHeaderSize = 12 - buf.writeUInt16LE(opts.width, 18); - buf.writeUInt16LE(opts.height, 20); - return buf; + const buf = Buffer.alloc(26); + buf.write('BM', 0, 'ascii'); + buf.writeUInt32LE(26, 2); + buf.writeUInt32LE(26, 10); + buf.writeUInt32LE(12, 14); // dibHeaderSize = 12 + buf.writeUInt16LE(opts.width, 18); + buf.writeUInt16LE(opts.height, 20); + return buf; } describe('readImageDimensions', () => { - describe('PNG', () => { - it('reads dimensions from a real encoded PNG', () => { - expect(readImageDimensions(encodeRgbPng(40, 24, [119, 119, 119]))).toEqual({ width: 40, height: 24 }); - }); - - it('reads dimensions from a hand-built IHDR header', () => { - expect(readImageDimensions(pngHeader(1280, 720))).toEqual({ width: 1280, height: 720 }); - }); - - it('rejects a truncated PNG (shorter than the IHDR block)', () => { - expect(readImageDimensions(pngHeader(10, 10).subarray(0, 32))).toBeUndefined(); - }); - - it('rejects a wrong signature', () => { - const buf = pngHeader(10, 10); - buf[1] = 0x00; // corrupt signature byte - expect(readImageDimensions(buf)).toBeUndefined(); - }); - - it('rejects when the IHDR tag is missing', () => { - const buf = pngHeader(10, 10); - buf.write('IDAT', 12, 'ascii'); // not IHDR at the expected offset - expect(readImageDimensions(buf)).toBeUndefined(); - }); - - it('rejects zero dimensions', () => { - expect(readImageDimensions(pngHeader(0, 10))).toBeUndefined(); - expect(readImageDimensions(pngHeader(10, 0))).toBeUndefined(); - }); - }); - - describe('JPEG', () => { - it('reads dimensions from a baseline SOF0 jpeg', () => { - expect(readImageDimensions(jpeg({ width: 250, height: 200 }))).toEqual({ width: 250, height: 200 }); - }); - - it('walks past an APP0 segment to reach the SOF marker', () => { - expect(readImageDimensions(jpeg({ width: 64, height: 48, withApp0: true }))).toEqual({ width: 64, height: 48 }); - }); - - it('reads dimensions from a progressive SOF2 jpeg', () => { - expect(readImageDimensions(jpeg({ width: 32, height: 16, marker: 0xc2 }))).toEqual({ width: 32, height: 16 }); - }); - - it('rejects bytes without an SOI marker', () => { - expect(readImageDimensions(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBeUndefined(); - }); - - it('rejects when a segment length runs past the buffer end', () => { - // SOI + SOF0 marker + length 0x0011 but no payload bytes follow. - expect(readImageDimensions(Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11]))).toBeUndefined(); - }); - - it('rejects an SOF segment whose declared length is too small for dimensions', () => { - // length 0x0005 (< 7) cannot contain precision + height + width. - const buf = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x05, 0x08, 0x00, 0x10, 0x00, 0x10]); - expect(readImageDimensions(buf)).toBeUndefined(); - }); - - it('rejects zero dimensions', () => { - expect(readImageDimensions(jpeg({ width: 0, height: 16 }))).toBeUndefined(); - }); - }); - - describe('GIF', () => { - it('reads dimensions from GIF89a', () => { - expect(readImageDimensions(gif({ width: 300, height: 200 }))).toEqual({ width: 300, height: 200 }); - }); - - it('reads dimensions from GIF87a', () => { - expect(readImageDimensions(gif({ width: 12, height: 34, version: '87a' }))).toEqual({ width: 12, height: 34 }); - }); - - it('rejects a too-short buffer', () => { - expect(readImageDimensions(Buffer.from('GIF89'))).toBeUndefined(); - }); - - it('rejects an unknown signature', () => { - expect(readImageDimensions(gif({ width: 10, height: 10 }).fill(0x00, 0, 6))).toBeUndefined(); - }); - - it('rejects zero dimensions', () => { - expect(readImageDimensions(gif({ width: 0, height: 10 }))).toBeUndefined(); - }); - }); - - describe('BMP', () => { - it('reads dimensions from a BITMAPINFOHEADER (40-byte DIB)', () => { - expect(readImageDimensions(bmpInfo({ width: 640, height: 480 }))).toEqual({ width: 640, height: 480 }); - }); - - it('treats a negative height (top-down DIB) as its absolute value', () => { - expect(readImageDimensions(bmpInfo({ width: 100, height: -50 }))).toEqual({ width: 100, height: 50 }); - }); - - it('reads dimensions from a BITMAPCOREHEADER (12-byte DIB)', () => { - expect(readImageDimensions(bmpCore({ width: 8, height: 8 }))).toEqual({ width: 8, height: 8 }); - }); - - it('rejects a too-short buffer', () => { - expect(readImageDimensions(Buffer.from('BM'))).toBeUndefined(); - }); - - it('rejects an invalid DIB header size', () => { - const buf = bmpInfo({ width: 10, height: 10 }); - buf.writeUInt32LE(8, 14); // dibHeaderSize < 12 - expect(readImageDimensions(buf)).toBeUndefined(); - }); - - it('rejects zero dimensions', () => { - expect(readImageDimensions(bmpInfo({ width: 0, height: 10 }))).toBeUndefined(); - }); - }); - - describe('unknown / non-image input', () => { - it('returns undefined for an empty buffer', () => { - expect(readImageDimensions(Buffer.alloc(0))).toBeUndefined(); - }); - - it('returns undefined for a WebP container (unsupported by the sniffer)', () => { - const webp = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPVP8 ')]); - expect(readImageDimensions(webp)).toBeUndefined(); - }); - }); + describe('PNG', () => { + it('reads dimensions from a real encoded PNG', () => { + expect(readImageDimensions(encodeRgbPng(40, 24, [119, 119, 119]))).toEqual({ width: 40, height: 24 }); + }); + + it('reads dimensions from a hand-built IHDR header', () => { + expect(readImageDimensions(pngHeader(1280, 720))).toEqual({ width: 1280, height: 720 }); + }); + + it('rejects a truncated PNG (shorter than the IHDR block)', () => { + expect(readImageDimensions(pngHeader(10, 10).subarray(0, 32))).toBeUndefined(); + }); + + it('rejects a wrong signature', () => { + const buf = pngHeader(10, 10); + buf[1] = 0x00; // corrupt signature byte + expect(readImageDimensions(buf)).toBeUndefined(); + }); + + it('rejects when the IHDR tag is missing', () => { + const buf = pngHeader(10, 10); + buf.write('IDAT', 12, 'ascii'); // not IHDR at the expected offset + expect(readImageDimensions(buf)).toBeUndefined(); + }); + + it('rejects zero dimensions', () => { + expect(readImageDimensions(pngHeader(0, 10))).toBeUndefined(); + expect(readImageDimensions(pngHeader(10, 0))).toBeUndefined(); + }); + }); + + describe('JPEG', () => { + it('reads dimensions from a baseline SOF0 jpeg', () => { + expect(readImageDimensions(jpeg({ width: 250, height: 200 }))).toEqual({ width: 250, height: 200 }); + }); + + it('walks past an APP0 segment to reach the SOF marker', () => { + expect(readImageDimensions(jpeg({ width: 64, height: 48, withApp0: true }))).toEqual({ width: 64, height: 48 }); + }); + + it('reads dimensions from a progressive SOF2 jpeg', () => { + expect(readImageDimensions(jpeg({ width: 32, height: 16, marker: 0xc2 }))).toEqual({ width: 32, height: 16 }); + }); + + it('rejects bytes without an SOI marker', () => { + expect(readImageDimensions(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBeUndefined(); + }); + + it('rejects when a segment length runs past the buffer end', () => { + // SOI + SOF0 marker + length 0x0011 but no payload bytes follow. + expect(readImageDimensions(Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11]))).toBeUndefined(); + }); + + it('rejects an SOF segment whose declared length is too small for dimensions', () => { + // length 0x0005 (< 7) cannot contain precision + height + width. + const buf = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x05, 0x08, 0x00, 0x10, 0x00, 0x10]); + expect(readImageDimensions(buf)).toBeUndefined(); + }); + + it('rejects zero dimensions', () => { + expect(readImageDimensions(jpeg({ width: 0, height: 16 }))).toBeUndefined(); + }); + }); + + describe('GIF', () => { + it('reads dimensions from GIF89a', () => { + expect(readImageDimensions(gif({ width: 300, height: 200 }))).toEqual({ width: 300, height: 200 }); + }); + + it('reads dimensions from GIF87a', () => { + expect(readImageDimensions(gif({ width: 12, height: 34, version: '87a' }))).toEqual({ width: 12, height: 34 }); + }); + + it('rejects a too-short buffer', () => { + expect(readImageDimensions(Buffer.from('GIF89'))).toBeUndefined(); + }); + + it('rejects an unknown signature', () => { + expect(readImageDimensions(gif({ width: 10, height: 10 }).fill(0x00, 0, 6))).toBeUndefined(); + }); + + it('rejects zero dimensions', () => { + expect(readImageDimensions(gif({ width: 0, height: 10 }))).toBeUndefined(); + }); + }); + + describe('BMP', () => { + it('reads dimensions from a BITMAPINFOHEADER (40-byte DIB)', () => { + expect(readImageDimensions(bmpInfo({ width: 640, height: 480 }))).toEqual({ width: 640, height: 480 }); + }); + + it('treats a negative height (top-down DIB) as its absolute value', () => { + expect(readImageDimensions(bmpInfo({ width: 100, height: -50 }))).toEqual({ width: 100, height: 50 }); + }); + + it('reads dimensions from a BITMAPCOREHEADER (12-byte DIB)', () => { + expect(readImageDimensions(bmpCore({ width: 8, height: 8 }))).toEqual({ width: 8, height: 8 }); + }); + + it('rejects a too-short buffer', () => { + expect(readImageDimensions(Buffer.from('BM'))).toBeUndefined(); + }); + + it('rejects an invalid DIB header size', () => { + const buf = bmpInfo({ width: 10, height: 10 }); + buf.writeUInt32LE(8, 14); // dibHeaderSize < 12 + expect(readImageDimensions(buf)).toBeUndefined(); + }); + + it('rejects zero dimensions', () => { + expect(readImageDimensions(bmpInfo({ width: 0, height: 10 }))).toBeUndefined(); + }); + }); + + describe('unknown / non-image input', () => { + it('returns undefined for an empty buffer', () => { + expect(readImageDimensions(Buffer.alloc(0))).toBeUndefined(); + }); + + it('returns undefined for a WebP container (unsupported by the sniffer)', () => { + const webp = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPVP8 ')]); + expect(readImageDimensions(webp)).toBeUndefined(); + }); + }); }); describe('exceedsImageLimits', () => { - const limits = { maxImageWidth: 299, maxImageHeight: 299, maxImagePixels: 299 * 299 }; + const limits = { maxImageWidth: 299, maxImageHeight: 299, maxImagePixels: 299 * 299 }; - it('allows dimensions within all limits', () => { - expect(exceedsImageLimits({ width: 200, height: 150 }, limits)).toBe(false); - }); + it('allows dimensions within all limits', () => { + expect(exceedsImageLimits({ width: 200, height: 150 }, limits)).toBe(false); + }); - it('allows dimensions exactly at the limit', () => { - expect(exceedsImageLimits({ width: 299, height: 299 }, limits)).toBe(false); - }); + it('allows dimensions exactly at the limit', () => { + expect(exceedsImageLimits({ width: 299, height: 299 }, limits)).toBe(false); + }); - it('rejects when width exceeds the limit', () => { - expect(exceedsImageLimits({ width: 300, height: 10 }, limits)).toBe(true); - }); + it('rejects when width exceeds the limit', () => { + expect(exceedsImageLimits({ width: 300, height: 10 }, limits)).toBe(true); + }); - it('rejects when height exceeds the limit', () => { - expect(exceedsImageLimits({ width: 10, height: 300 }, limits)).toBe(true); - }); + it('rejects when height exceeds the limit', () => { + expect(exceedsImageLimits({ width: 10, height: 300 }, limits)).toBe(true); + }); - it('rejects when the pixel count exceeds the limit even if each side is within bounds', () => { - // 各辺は上限内だが総ピクセルが上限を超えるケース(BigInt 乗算でオーバーフローしないことも兼ねる)。 - const wide = { maxImageWidth: 100_000, maxImageHeight: 100_000, maxImagePixels: 10_000 }; - expect(exceedsImageLimits({ width: 200, height: 200 }, wide)).toBe(true); - }); + it('rejects when the pixel count exceeds the limit even if each side is within bounds', () => { + // 各辺は上限内だが総ピクセルが上限を超えるケース(BigInt 乗算でオーバーフローしないことも兼ねる)。 + const wide = { maxImageWidth: 100_000, maxImageHeight: 100_000, maxImagePixels: 10_000 }; + expect(exceedsImageLimits({ width: 200, height: 200 }, wide)).toBe(true); + }); }); diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index 8976243..c252648 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -1,12 +1,12 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['./src/main.ts'], - outDir: 'dist', - format: ['esm'], - platform: 'node', - target: 'node22', - dts: false, - sourcemap: true, - clean: true, + entry: ['./src/main.ts'], + outDir: 'dist', + format: ['esm'], + platform: 'node', + target: 'node22', + dts: false, + sourcemap: true, + clean: true, }); diff --git a/config.docker.mjs b/config.docker.mjs index 6289a17..afefd47 100644 --- a/config.docker.mjs +++ b/config.docker.mjs @@ -8,14 +8,14 @@ * @type {import('./packages/core/src/index.js').Config} */ export default { - // コンテナ内では 0.0.0.0:3009 で待ち受け、compose 側で公開ポートにマッピングする - // (ホスト側の公開範囲は compose.yml の HOST_BIND(既定 127.0.0.1)で絞る)。 - port: 3009, - host: '0.0.0.0', + // コンテナ内では 0.0.0.0:3009 で待ち受け、compose 側で公開ポートにマッピングする + // (ホスト側の公開範囲は compose.yml の HOST_BIND(既定 127.0.0.1)で絞る)。 + port: 3009, + host: '0.0.0.0', - // モデルは Dockerfile で /models へ同梱済み。 - modelDir: '/models', + // モデルは Dockerfile で /models へ同梱済み。 + modelDir: '/models', - // 静的な共有シークレット。TCP 待ち受けでは apiKey 必須。空のまま起動すると ConfigError で落ちる。 - apiKey: process.env.SENSITIVE_DETECTOR_API_KEY, + // 静的な共有シークレット。TCP 待ち受けでは apiKey 必須。空のまま起動すると ConfigError で落ちる。 + apiKey: process.env.SENSITIVE_DETECTOR_API_KEY, }; diff --git a/config.example.mjs b/config.example.mjs index 6229f3c..f6f1848 100644 --- a/config.example.mjs +++ b/config.example.mjs @@ -9,32 +9,32 @@ import path from 'node:path'; * @type {import('./packages/core/src/index.js').Config} */ export default { - // --- 待ち受け(port と socket はどちらか一方が必須) --- - port: 3009, - // bind ホスト(port 待ち受け時のみ)。既定は 127.0.0.1(ローカルのみ)。 - // 外部公開する場合のみ '0.0.0.0' を明示する(コンテナは config.docker.mjs で 0.0.0.0 指定済み)。 - // host: '127.0.0.1', - // socket: '/run/sensitive-detector/app.sock', + // --- 待ち受け(port と socket はどちらか一方が必須) --- + port: 3009, + // bind ホスト(port 待ち受け時のみ)。既定は 127.0.0.1(ローカルのみ)。 + // 外部公開する場合のみ '0.0.0.0' を明示する(コンテナは config.docker.mjs で 0.0.0.0 指定済み)。 + // host: '127.0.0.1', + // socket: '/run/sensitive-detector/app.sock', - // --- モデル(必須) --- - // Misskey 本体にあった packages/backend/nsfw-model に相当するディレクトリ。 - modelDir: path.resolve(process.env.INIT_CWD || process.cwd(), 'nsfw-model/'), + // --- モデル(必須) --- + // Misskey 本体にあった packages/backend/nsfw-model に相当するディレクトリ。 + modelDir: path.resolve(process.env.INIT_CWD || process.cwd(), 'nsfw-model/'), - // --- 認証 --- - // 静的な共有シークレット。自分で十分長いランダムな値を決めて、 - // リクエスト元には Authorization: Bearer <この値> として送らせる。 - // port で TCP 待ち受けする場合は apiKey か allowUnauthenticatedTcp: true が必須。 - apiKey: 'change-me-to-a-long-random-secret', - // 開発用など、外部から到達不能な TCP で apiKey 未設定を明示的に許す場合のみ true。 - // allowUnauthenticatedTcp: true, + // --- 認証 --- + // 静的な共有シークレット。自分で十分長いランダムな値を決めて、 + // リクエスト元には Authorization: Bearer <この値> として送らせる。 + // port で TCP 待ち受けする場合は apiKey か allowUnauthenticatedTcp: true が必須。 + apiKey: 'change-me-to-a-long-random-secret', + // 開発用など、外部から到達不能な TCP で apiKey 未設定を明示的に許す場合のみ true。 + // allowUnauthenticatedTcp: true, - // --- リクエスト制限(任意。以下はデフォルト値) --- - maxBinarySize: 1_048_576, // 1MB(パーツ個別の上限) - maxImageWidth: 299, - maxImageHeight: 299, - maxImagePixels: 89_401, // 299 × 299 - maxParts: 10, // multipart パーツ数の上限 - maxBodySize: 12_582_912, // 12MB(総 body の上限: 10 parts × 1MB + boundary/header 余白) - maxConcurrentJobs: 2, - requestTimeoutMs: 60_000, + // --- リクエスト制限(任意。以下はデフォルト値) --- + maxBinarySize: 1_048_576, // 1MB(パーツ個別の上限) + maxImageWidth: 299, + maxImageHeight: 299, + maxImagePixels: 89_401, // 299 × 299 + maxParts: 10, // multipart パーツ数の上限 + maxBodySize: 12_582_912, // 12MB(総 body の上限: 10 parts × 1MB + boundary/header 余白) + maxConcurrentJobs: 2, + requestTimeoutMs: 60_000, }; diff --git a/oxlint.config.ts b/oxlint.config.ts index b04e04a..b681e3f 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,28 +1,28 @@ import { - defineMisskeyDevOxlintConfig, - oxlintJsRules, - oxlintStylisticRules, + defineMisskeyDevOxlintConfig, + oxlintJsRules, + oxlintStylisticRules, } from '@misskey-dev/oxlint-fmt-config'; export default defineMisskeyDevOxlintConfig({ - overrides: { - ignorePatterns: ['**/dist/**', 'nsfw-model/**'], - overrides: [ - { - // 共通設定の JS 向けルールは *.js / *.jsx のみが対象のため、ESM / CJS スクリプトにも適用する。 - files: ['**/*.mjs', '**/*.cjs'], - rules: { - ...oxlintJsRules, - ...oxlintStylisticRules, - }, - }, - { - // 設定ファイルはツール側の規約で default export が必須。 - files: ['**/*.config.ts', 'config.*.mjs'], - rules: { - 'import/no-default-export': 'off', - }, - }, - ], - }, + overrides: { + ignorePatterns: ['**/dist/**', 'nsfw-model/**'], + overrides: [ + { + // 共通設定の JS 向けルールは *.js / *.jsx のみが対象のため、ESM / CJS スクリプトにも適用する。 + files: ['**/*.mjs', '**/*.cjs'], + rules: { + ...oxlintJsRules, + ...oxlintStylisticRules, + }, + }, + { + // 設定ファイルはツール側の規約で default export が必須。 + files: ['**/*.config.ts', 'config.*.mjs'], + rules: { + 'import/no-default-export': 'off', + }, + }, + ], + }, }); diff --git a/package.json b/package.json index 5acdce8..eef191a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "vitest run", "test:unit": "vitest run --project unit", "test:integration": "vitest run --project integration", - "lint": "oxlint", + "lint": "oxlint --deny-warnings", "lint:fix": "oxlint --fix" }, "devDependencies": { diff --git a/packages/core/src/classifier.ts b/packages/core/src/classifier.ts index 0333d4b..1a1947a 100644 --- a/packages/core/src/classifier.ts +++ b/packages/core/src/classifier.ts @@ -6,37 +6,37 @@ import type { DetectErrorCode, Prediction } from './types.js'; * core が利用する最小ロガー。server の pino Logger は構造的にこの型へ代入できる。 */ export type CoreLogger = { - info: (msg: string) => void; - warn: (msg: string) => void; - error: (msg: string) => void; + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; }; const noopLogger: CoreLogger = { - info: () => undefined, - warn: () => undefined, - error: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, }; // --- 実依存(onnxruntime-node)の最小構造型 --- // テストでは fake を注入できる。 type OnnxSession = { - run(feeds: Record): Promise>; + run(feeds: Record): Promise>; }; type OnnxSessionOptions = { - intraOpNumThreads?: number; + intraOpNumThreads?: number; }; type OnnxInferenceSession = { - create(path: string, options?: OnnxSessionOptions): Promise; + create(path: string, options?: OnnxSessionOptions): Promise; }; type OnnxTensorCtor = new (type: string, data: Float32Array, dims: number[]) => unknown; type OnnxModule = { - InferenceSession: OnnxInferenceSession; - Tensor: OnnxTensorCtor; + InferenceSession: OnnxInferenceSession; + Tensor: OnnxTensorCtor; }; const defaultLoadOnnx = async (): Promise => (await import('onnxruntime-node')) as unknown as OnnxModule; @@ -45,7 +45,7 @@ const defaultLoadOnnx = async (): Promise => (await import('onnxrunt * ONNX Runtime を実行できる CPU/アーキテクチャかを判定する(参照: Misskey本体バックエンドのAiService.ts)。 */ export async function computeIsSupportedCpu(arch: string = process.arch): Promise { - return arch === 'x64' || arch === 'arm64'; + return arch === 'x64' || arch === 'arm64'; } /** モデル入力サイズ(InceptionV3)。 */ @@ -62,24 +62,24 @@ const OUTPUT_NAME = 'dense_3'; * デコード失敗・サイズ不一致は undefined を返す。 */ function decodePngToFloat32(buffer: Buffer): Float32Array | undefined { - let png: PNG; - try { - png = PNG.sync.read(buffer); - } catch { - return undefined; - } - if (png.width !== MODEL_SIZE || png.height !== MODEL_SIZE) { - return undefined; - } - // PNG.sync.read は RGBA (4ch) を返す。RGB 3ch に変換しつつ [0, 1] に正規化する。 - const pixelCount = MODEL_SIZE * MODEL_SIZE; - const float32 = new Float32Array(pixelCount * 3); - for (let i = 0; i < pixelCount; i++) { - float32[i * 3] = (png.data[i * 4] ?? 0) / 255; - float32[i * 3 + 1] = (png.data[i * 4 + 1] ?? 0) / 255; - float32[i * 3 + 2] = (png.data[i * 4 + 2] ?? 0) / 255; - } - return float32; + let png: PNG; + try { + png = PNG.sync.read(buffer); + } catch { + return undefined; + } + if (png.width !== MODEL_SIZE || png.height !== MODEL_SIZE) { + return undefined; + } + // PNG.sync.read は RGBA (4ch) を返す。RGB 3ch に変換しつつ [0, 1] に正規化する。 + const pixelCount = MODEL_SIZE * MODEL_SIZE; + const float32 = new Float32Array(pixelCount * 3); + for (let i = 0; i < pixelCount; i++) { + float32[i * 3] = (png.data[i * 4] ?? 0) / 255; + float32[i * 3 + 1] = (png.data[i * 4 + 1] ?? 0) / 255; + float32[i * 3 + 2] = (png.data[i * 4 + 2] ?? 0) / 255; + } + return float32; } export type ClassifyResult = @@ -87,53 +87,53 @@ export type ClassifyResult = | { ok: false; code: Extract }; export interface Classifier { - /** モデルが利用可能か(起動時診断用)。false の場合 classify は常に MODEL_UNAVAILABLE を返す。 */ - readonly available: boolean; - classify(buffer: Buffer): Promise; + /** モデルが利用可能か(起動時診断用)。false の場合 classify は常に MODEL_UNAVAILABLE を返す。 */ + readonly available: boolean; + classify(buffer: Buffer): Promise; } export type ClassifierDeps = { - arch?: string; - loadOnnx?: () => Promise; - logger?: CoreLogger; - intraOpNumThreads?: number; + arch?: string; + loadOnnx?: () => Promise; + logger?: CoreLogger; + intraOpNumThreads?: number; }; function unavailableClassifier(): Classifier { - return { - available: false, - classify: () => Promise.resolve({ ok: false, code: 'MODEL_UNAVAILABLE' }), - }; + return { + available: false, + classify: () => Promise.resolve({ ok: false, code: 'MODEL_UNAVAILABLE' }), + }; } function readyClassifier(session: OnnxSession, TensorCtor: OnnxTensorCtor): Classifier { - return { - available: true, - classify: async (buffer: Buffer): Promise => { - const float32 = decodePngToFloat32(buffer); - if (float32 === undefined) { - return { ok: false, code: 'IMAGE_DECODE_FAILED' }; - } - try { - const inputTensor = new TensorCtor('float32', float32, [1, MODEL_SIZE, MODEL_SIZE, 3]); - const results = await session.run({ [INPUT_NAME]: inputTensor }); - const output = results[OUTPUT_NAME]; - if (!output) { - return { ok: false, code: 'DETECTION_FAILED' }; - } - const data = output.data as Float32Array; - const predictions: Prediction[] = CLASS_NAMES.map((className, i) => ({ - className, - probability: data[i] ?? 0, - })); - // nsfwjs 互換: 確率降順でソートする。 - predictions.sort((a, b) => b.probability - a.probability); - return { ok: true, predictions }; - } catch { - return { ok: false, code: 'DETECTION_FAILED' }; - } - }, - }; + return { + available: true, + classify: async (buffer: Buffer): Promise => { + const float32 = decodePngToFloat32(buffer); + if (float32 === undefined) { + return { ok: false, code: 'IMAGE_DECODE_FAILED' }; + } + try { + const inputTensor = new TensorCtor('float32', float32, [1, MODEL_SIZE, MODEL_SIZE, 3]); + const results = await session.run({ [INPUT_NAME]: inputTensor }); + const output = results[OUTPUT_NAME]; + if (!output) { + return { ok: false, code: 'DETECTION_FAILED' }; + } + const data = output.data as Float32Array; + const predictions: Prediction[] = CLASS_NAMES.map((className, i) => ({ + className, + probability: data[i] ?? 0, + })); + // nsfwjs 互換: 確率降順でソートする。 + predictions.sort((a, b) => b.probability - a.probability); + return { ok: true, predictions }; + } catch { + return { ok: false, code: 'DETECTION_FAILED' }; + } + }, + }; } /** @@ -142,31 +142,31 @@ function readyClassifier(session: OnnxSession, TensorCtor: OnnxTensorCtor): Clas * 再試行しない(要件: 早期検出方針)。 */ export async function createClassifier(modelDir: string, deps: ClassifierDeps = {}): Promise { - const logger = deps.logger ?? noopLogger; - - const supported = await computeIsSupportedCpu(deps.arch); - if (!supported) { - logger.error( - 'ONNX Runtime is not supported on this CPU/architecture; /v1/detect-image will always return MODEL_UNAVAILABLE.', - ); - return unavailableClassifier(); - } - - try { - const loadOnnx = deps.loadOnnx ?? defaultLoadOnnx; - const { InferenceSession, Tensor } = await loadOnnx(); - - const modelPath = join(modelDir, 'nsfw_model.onnx'); - const sessionOptions: OnnxSessionOptions = {}; - if (deps.intraOpNumThreads !== undefined && deps.intraOpNumThreads > 0) { - sessionOptions.intraOpNumThreads = deps.intraOpNumThreads; - } - const session = await InferenceSession.create(modelPath, sessionOptions); - logger.info('ONNX model loaded successfully.'); - return readyClassifier(session, Tensor); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error(`Failed to load ONNX model: ${message}`); - return unavailableClassifier(); - } + const logger = deps.logger ?? noopLogger; + + const supported = await computeIsSupportedCpu(deps.arch); + if (!supported) { + logger.error( + 'ONNX Runtime is not supported on this CPU/architecture; /v1/detect-image will always return MODEL_UNAVAILABLE.', + ); + return unavailableClassifier(); + } + + try { + const loadOnnx = deps.loadOnnx ?? defaultLoadOnnx; + const { InferenceSession, Tensor } = await loadOnnx(); + + const modelPath = join(modelDir, 'nsfw_model.onnx'); + const sessionOptions: OnnxSessionOptions = {}; + if (deps.intraOpNumThreads !== undefined && deps.intraOpNumThreads > 0) { + sessionOptions.intraOpNumThreads = deps.intraOpNumThreads; + } + const session = await InferenceSession.create(modelPath, sessionOptions); + logger.info('ONNX model loaded successfully.'); + return readyClassifier(session, Tensor); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Failed to load ONNX model: ${message}`); + return unavailableClassifier(); + } } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e718700..3351604 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -16,43 +16,43 @@ const MAX_TIMEOUT_MS = 2_147_483_647; * config ファイル(.mjs/.cjs)が default export する設定。 */ export type Config = { - /** + /** * 設定するとHTTPとしてListenする。 * port と socket はどちらか一方が必須(両方指定・両方未指定はエラー)。 */ - port?: number; - /** + port?: number; + /** * 設定するとUnixドメインソケットとしてListenする。 * port と socket はどちらか一方が必須(両方指定・両方未指定はエラー)。 */ - socket?: string; - /** + socket?: string; + /** * port 待ち受け時の bind ホスト。デフォルト 127.0.0.1(ローカルのみ)。 * コンテナ等で外部公開する場合のみ '0.0.0.0' を明示する。socket 待ち受けでは無視される。 */ - host?: string; - /** ONNX モデルディレクトリ(必須)。 */ - modelDir: string; - /** 静的な共有シークレット(設定者が決めて、リクエスト元と共有する)。設定すると `Authorization: Bearer <この値>` ヘッダーが必須になる。 */ - apiKey?: string; - /** TCP 待ち受けで apiKey 未設定を許すためのフラグ。Unix socket では不要。外部から到達不能な開発環境向け。 */ - allowUnauthenticatedTcp?: boolean; - /** バイナリ入力の上限バイト数。デフォルト 1MB。 */ - maxBinarySize?: number; - /** デコード後画像幅の上限。デフォルト 299px。 */ - maxImageWidth?: number; - /** デコード後画像高さの上限。デフォルト 299px。 */ - maxImageHeight?: number; - /** デコード後総ピクセル数の上限。デフォルト 299×299。 */ - maxImagePixels?: number; - /** multipart リクエストの最大パーツ数。デフォルト 10。 */ - maxParts?: number; - /** multipart リクエストの総 body 上限バイト数。デフォルト 12MB。 */ - maxBodySize?: number; - /** モデル推論の同時実行上限。デフォルト 2。 */ - maxConcurrentJobs?: number; - /** リクエストタイムアウト(ms)。デフォルト 60000。 */ - requestTimeoutMs?: number; + host?: string; + /** ONNX モデルディレクトリ(必須)。 */ + modelDir: string; + /** 静的な共有シークレット(設定者が決めて、リクエスト元と共有する)。設定すると `Authorization: Bearer <この値>` ヘッダーが必須になる。 */ + apiKey?: string; + /** TCP 待ち受けで apiKey 未設定を許すためのフラグ。Unix socket では不要。外部から到達不能な開発環境向け。 */ + allowUnauthenticatedTcp?: boolean; + /** バイナリ入力の上限バイト数。デフォルト 1MB。 */ + maxBinarySize?: number; + /** デコード後画像幅の上限。デフォルト 299px。 */ + maxImageWidth?: number; + /** デコード後画像高さの上限。デフォルト 299px。 */ + maxImageHeight?: number; + /** デコード後総ピクセル数の上限。デフォルト 299×299。 */ + maxImagePixels?: number; + /** multipart リクエストの最大パーツ数。デフォルト 10。 */ + maxParts?: number; + /** multipart リクエストの総 body 上限バイト数。デフォルト 12MB。 */ + maxBodySize?: number; + /** モデル推論の同時実行上限。デフォルト 2。 */ + maxConcurrentJobs?: number; + /** リクエストタイムアウト(ms)。デフォルト 60000。 */ + requestTimeoutMs?: number; }; export type ResolvedListen = { kind: 'port'; port: number; host: string } | { kind: 'socket'; socket: string }; @@ -61,51 +61,51 @@ export type ResolvedListen = { kind: 'port'; port: number; host: string } | { ki * 全デフォルトを適用し、不変条件を満たした設定。 */ export type ResolvedConfig = { - listen: ResolvedListen; - modelDir: string; - apiKey?: string; - allowUnauthenticatedTcp: boolean; - maxBinarySize: number; - maxImageWidth: number; - maxImageHeight: number; - maxImagePixels: number; - maxParts: number; - maxBodySize: number; - maxConcurrentJobs: number; - requestTimeoutMs: number; + listen: ResolvedListen; + modelDir: string; + apiKey?: string; + allowUnauthenticatedTcp: boolean; + maxBinarySize: number; + maxImageWidth: number; + maxImageHeight: number; + maxImagePixels: number; + maxParts: number; + maxBodySize: number; + maxConcurrentJobs: number; + requestTimeoutMs: number; }; export class ConfigError extends Error { - constructor(message: string) { - super(message); - this.name = 'ConfigError'; - } + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } } // Config の全キーを 1 箇所で列挙する(値の true はダミー)。`satisfies Record` により // Config 型と乖離しなくなる: フィールドを足すと「列挙漏れ」、タイポを書くと「未知プロパティ」で typecheck が落ちる。 const CONFIG_KEYS = { - port: true, - socket: true, - host: true, - modelDir: true, - apiKey: true, - allowUnauthenticatedTcp: true, - maxBinarySize: true, - maxImageWidth: true, - maxImageHeight: true, - maxImagePixels: true, - maxParts: true, - maxBodySize: true, - maxConcurrentJobs: true, - requestTimeoutMs: true, + port: true, + socket: true, + host: true, + modelDir: true, + apiKey: true, + allowUnauthenticatedTcp: true, + maxBinarySize: true, + maxImageWidth: true, + maxImageHeight: true, + maxImagePixels: true, + maxParts: true, + maxBodySize: true, + maxConcurrentJobs: true, + requestTimeoutMs: true, } satisfies Record; /** 受理する config キー一覧(Config 型と一致。CONFIG_KEYS の型チェックで網羅・タイポを担保)。未知キー検出に使う。 */ export const KNOWN_CONFIG_KEYS = Object.keys(CONFIG_KEYS) as readonly (keyof Config)[]; function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null && !Array.isArray(value); } /** @@ -114,19 +114,19 @@ function isPlainObject(value: unknown): value is Record { * raw がオブジェクトでなければ空配列(その場合 validateConfig が ConfigError を投げる)。 */ export function unknownConfigKeys(raw: unknown): string[] { - if (!isPlainObject(raw)) { - return []; - } - const known = new Set(KNOWN_CONFIG_KEYS); - return Object.keys(raw).filter((key) => !known.has(key)); + if (!isPlainObject(raw)) { + return []; + } + const known = new Set(KNOWN_CONFIG_KEYS); + return Object.keys(raw).filter((key) => !known.has(key)); } function requirePositiveInt(value: unknown, field: string, max: number = Number.MAX_SAFE_INTEGER): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > max) { - const bound = max === Number.MAX_SAFE_INTEGER ? '' : ` and ≤ ${max}`; - throw new ConfigError(`config.${field} must be a positive integer${bound}, got ${JSON.stringify(value)}`); - } - return value; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > max) { + const bound = max === Number.MAX_SAFE_INTEGER ? '' : ` and ≤ ${max}`; + throw new ConfigError(`config.${field} must be a positive integer${bound}, got ${JSON.stringify(value)}`); + } + return value; } /** @@ -134,112 +134,112 @@ function requirePositiveInt(value: unknown, field: string, max: number = Number. * 検証に失敗した場合は `ConfigError` を throw する(起動時に process.exit(1) させる想定)。 */ export function validateConfig(raw: unknown): ResolvedConfig { - if (!isPlainObject(raw)) { - throw new ConfigError('config must be an object (the default export of the config module)'); - } - - // --- modelDir(必須) --- - const { modelDir } = raw; - if (typeof modelDir !== 'string' || modelDir.length === 0) { - throw new ConfigError('config.modelDir is required and must be a non-empty string'); - } - const normalizedModelDir = modelDir.endsWith('/') ? modelDir : `${modelDir}/`; - - // --- listen(port XOR socket) --- - const hasPort = raw.port !== undefined; - const hasSocket = raw.socket !== undefined; - if (hasPort && hasSocket) { - throw new ConfigError('config must set exactly one of port or socket, but both are set'); - } - if (!hasPort && !hasSocket) { - throw new ConfigError('config must set exactly one of port or socket, but neither is set'); - } - - let listen: ResolvedListen; - if (hasPort) { - const { port } = raw; - if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) { - throw new ConfigError(`config.port must be an integer between 1 and 65535, got ${JSON.stringify(port)}`); - } - let host = DEFAULT_HOST; - if (raw.host !== undefined) { - if (typeof raw.host !== 'string' || raw.host.length === 0) { - throw new ConfigError(`config.host must be a non-empty string when set, got ${JSON.stringify(raw.host)}`); - } - host = raw.host; - } - listen = { kind: 'port', port, host }; - } else { - const { socket } = raw; - if (typeof socket !== 'string' || socket.length === 0) { - throw new ConfigError('config.socket must be a non-empty string'); - } - if (raw.host !== undefined) { - throw new ConfigError('config.host is only valid with a port listener, not socket'); - } - listen = { kind: 'socket', socket }; - } - - // --- apiKey(任意) --- - let apiKey: string | undefined; - if (raw.apiKey !== undefined) { - if (typeof raw.apiKey !== 'string' || raw.apiKey.length === 0) { - throw new ConfigError('config.apiKey must be a non-empty string when set'); - } - apiKey = raw.apiKey; - } - let allowUnauthenticatedTcp = false; - if (raw.allowUnauthenticatedTcp !== undefined) { - if (typeof raw.allowUnauthenticatedTcp !== 'boolean') { - throw new ConfigError( - `config.allowUnauthenticatedTcp must be a boolean when set, got ${JSON.stringify(raw.allowUnauthenticatedTcp)}`, - ); - } - allowUnauthenticatedTcp = raw.allowUnauthenticatedTcp; - } - if (listen.kind === 'port' && apiKey === undefined && !allowUnauthenticatedTcp) { - throw new ConfigError( - 'config.apiKey is required when using port; set allowUnauthenticatedTcp: true to explicitly allow unauthenticated TCP', - ); - } - - // --- 数値系(任意、デフォルトあり) --- - const maxBinarySize = - raw.maxBinarySize === undefined ? DEFAULT_MAX_BINARY_SIZE : requirePositiveInt(raw.maxBinarySize, 'maxBinarySize'); - const maxImageWidth = - raw.maxImageWidth === undefined ? DEFAULT_MAX_IMAGE_WIDTH : requirePositiveInt(raw.maxImageWidth, 'maxImageWidth'); - const maxImageHeight = - raw.maxImageHeight === undefined - ? DEFAULT_MAX_IMAGE_HEIGHT - : requirePositiveInt(raw.maxImageHeight, 'maxImageHeight'); - const maxImagePixels = - raw.maxImagePixels === undefined - ? DEFAULT_MAX_IMAGE_PIXELS - : requirePositiveInt(raw.maxImagePixels, 'maxImagePixels'); - const maxParts = raw.maxParts === undefined ? DEFAULT_MAX_PARTS : requirePositiveInt(raw.maxParts, 'maxParts'); - const maxBodySize = - raw.maxBodySize === undefined ? DEFAULT_MAX_BODY_SIZE : requirePositiveInt(raw.maxBodySize, 'maxBodySize'); - const maxConcurrentJobs = - raw.maxConcurrentJobs === undefined - ? DEFAULT_MAX_CONCURRENT_JOBS - : requirePositiveInt(raw.maxConcurrentJobs, 'maxConcurrentJobs'); - const requestTimeoutMs = - raw.requestTimeoutMs === undefined - ? DEFAULT_REQUEST_TIMEOUT_MS - : requirePositiveInt(raw.requestTimeoutMs, 'requestTimeoutMs', MAX_TIMEOUT_MS); - - return { - listen, - modelDir: normalizedModelDir, - apiKey, - allowUnauthenticatedTcp, - maxBinarySize, - maxImageWidth, - maxImageHeight, - maxImagePixels, - maxParts, - maxBodySize, - maxConcurrentJobs, - requestTimeoutMs, - }; + if (!isPlainObject(raw)) { + throw new ConfigError('config must be an object (the default export of the config module)'); + } + + // --- modelDir(必須) --- + const { modelDir } = raw; + if (typeof modelDir !== 'string' || modelDir.length === 0) { + throw new ConfigError('config.modelDir is required and must be a non-empty string'); + } + const normalizedModelDir = modelDir.endsWith('/') ? modelDir : `${modelDir}/`; + + // --- listen(port XOR socket) --- + const hasPort = raw.port !== undefined; + const hasSocket = raw.socket !== undefined; + if (hasPort && hasSocket) { + throw new ConfigError('config must set exactly one of port or socket, but both are set'); + } + if (!hasPort && !hasSocket) { + throw new ConfigError('config must set exactly one of port or socket, but neither is set'); + } + + let listen: ResolvedListen; + if (hasPort) { + const { port } = raw; + if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new ConfigError(`config.port must be an integer between 1 and 65535, got ${JSON.stringify(port)}`); + } + let host = DEFAULT_HOST; + if (raw.host !== undefined) { + if (typeof raw.host !== 'string' || raw.host.length === 0) { + throw new ConfigError(`config.host must be a non-empty string when set, got ${JSON.stringify(raw.host)}`); + } + host = raw.host; + } + listen = { kind: 'port', port, host }; + } else { + const { socket } = raw; + if (typeof socket !== 'string' || socket.length === 0) { + throw new ConfigError('config.socket must be a non-empty string'); + } + if (raw.host !== undefined) { + throw new ConfigError('config.host is only valid with a port listener, not socket'); + } + listen = { kind: 'socket', socket }; + } + + // --- apiKey(任意) --- + let apiKey: string | undefined; + if (raw.apiKey !== undefined) { + if (typeof raw.apiKey !== 'string' || raw.apiKey.length === 0) { + throw new ConfigError('config.apiKey must be a non-empty string when set'); + } + apiKey = raw.apiKey; + } + let allowUnauthenticatedTcp = false; + if (raw.allowUnauthenticatedTcp !== undefined) { + if (typeof raw.allowUnauthenticatedTcp !== 'boolean') { + throw new ConfigError( + `config.allowUnauthenticatedTcp must be a boolean when set, got ${JSON.stringify(raw.allowUnauthenticatedTcp)}`, + ); + } + allowUnauthenticatedTcp = raw.allowUnauthenticatedTcp; + } + if (listen.kind === 'port' && apiKey === undefined && !allowUnauthenticatedTcp) { + throw new ConfigError( + 'config.apiKey is required when using port; set allowUnauthenticatedTcp: true to explicitly allow unauthenticated TCP', + ); + } + + // --- 数値系(任意、デフォルトあり) --- + const maxBinarySize = + raw.maxBinarySize === undefined ? DEFAULT_MAX_BINARY_SIZE : requirePositiveInt(raw.maxBinarySize, 'maxBinarySize'); + const maxImageWidth = + raw.maxImageWidth === undefined ? DEFAULT_MAX_IMAGE_WIDTH : requirePositiveInt(raw.maxImageWidth, 'maxImageWidth'); + const maxImageHeight = + raw.maxImageHeight === undefined + ? DEFAULT_MAX_IMAGE_HEIGHT + : requirePositiveInt(raw.maxImageHeight, 'maxImageHeight'); + const maxImagePixels = + raw.maxImagePixels === undefined + ? DEFAULT_MAX_IMAGE_PIXELS + : requirePositiveInt(raw.maxImagePixels, 'maxImagePixels'); + const maxParts = raw.maxParts === undefined ? DEFAULT_MAX_PARTS : requirePositiveInt(raw.maxParts, 'maxParts'); + const maxBodySize = + raw.maxBodySize === undefined ? DEFAULT_MAX_BODY_SIZE : requirePositiveInt(raw.maxBodySize, 'maxBodySize'); + const maxConcurrentJobs = + raw.maxConcurrentJobs === undefined + ? DEFAULT_MAX_CONCURRENT_JOBS + : requirePositiveInt(raw.maxConcurrentJobs, 'maxConcurrentJobs'); + const requestTimeoutMs = + raw.requestTimeoutMs === undefined + ? DEFAULT_REQUEST_TIMEOUT_MS + : requirePositiveInt(raw.requestTimeoutMs, 'requestTimeoutMs', MAX_TIMEOUT_MS); + + return { + listen, + modelDir: normalizedModelDir, + apiKey, + allowUnauthenticatedTcp, + maxBinarySize, + maxImageWidth, + maxImageHeight, + maxImagePixels, + maxParts, + maxBodySize, + maxConcurrentJobs, + requestTimeoutMs, + }; } diff --git a/packages/core/src/detect.ts b/packages/core/src/detect.ts index 7559988..dfc4be4 100644 --- a/packages/core/src/detect.ts +++ b/packages/core/src/detect.ts @@ -5,11 +5,11 @@ import type { Semaphore } from './semaphore.js'; import type { DetectImageResult } from './types.js'; export type DetectContext = { - classifier: Classifier; - semaphore: Semaphore; - requestTimeoutMs: number; - /** 任意。タイムアウト後も解放されないスロット(hung)を /health に反映するためのトラッカー。 */ - health?: InferenceHealth; + classifier: Classifier; + semaphore: Semaphore; + requestTimeoutMs: number; + /** 任意。タイムアウト後も解放されないスロット(hung)を /health に反映するためのトラッカー。 */ + health?: InferenceHealth; }; /** @@ -25,51 +25,51 @@ export type DetectContext = { * hung 発生時は外部ヘルスチェックでプロセスを再起動する運用を推奨する。 */ export async function detectImage(buffer: Buffer, ctx: DetectContext): Promise { - const controller = new AbortController(); - let timer: ReturnType | undefined; - // タイムアウト発火時にスロットを「詰まり」として記録したか。work 完了時に戻すために覚えておく。 - let markedStuck = false; + const controller = new AbortController(); + let timer: ReturnType | undefined; + // タイムアウト発火時にスロットを「詰まり」として記録したか。work 完了時に戻すために覚えておく。 + let markedStuck = false; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - controller.abort(); - // タイムアウト発火=この時点で work は未完了。走り出した推論はスロットを保持したまま裏で - // 走り続ける(or 二度と完了しない)可能性があるため、liveness 判定に「詰まり」を 1 件記録する。 - // 注: スロット未取得(待機キューで abort)の場合も一旦 +1 するが、acquire の reject により - // 直後の onSettled で -1 され microtask 内で相殺する(saturated を誤って立て続けることはない)。 - if (ctx.health) { - markedStuck = true; - ctx.health.markStuck(); - } - reject(new DetectError('DETECTION_FAILED', `inference timed out after ${ctx.requestTimeoutMs}ms`)); - }, ctx.requestTimeoutMs); - }); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + // タイムアウト発火=この時点で work は未完了。走り出した推論はスロットを保持したまま裏で + // 走り続ける(or 二度と完了しない)可能性があるため、liveness 判定に「詰まり」を 1 件記録する。 + // 注: スロット未取得(待機キューで abort)の場合も一旦 +1 するが、acquire の reject により + // 直後の onSettled で -1 され microtask 内で相殺する(saturated を誤って立て続けることはない)。 + if (ctx.health) { + markedStuck = true; + ctx.health.markStuck(); + } + reject(new DetectError('DETECTION_FAILED', `inference timed out after ${ctx.requestTimeoutMs}ms`)); + }, ctx.requestTimeoutMs); + }); - const work = ctx.semaphore.runExclusive(() => ctx.classifier.classify(buffer), controller.signal); - // タイムアウトが先に解決した場合に work の遅延 rejection が unhandled にならないよう握りつぶす。 - work.catch(() => undefined); - // work が(裏で)最終的に解決/失敗したら、記録した「詰まり」を戻して自己回復させる。 - // 永続 hung の場合はここに到達しないため、saturated が立ったまま /health が 503 を返し続ける。 - if (ctx.health) { - const onSettled = (): void => { - if (markedStuck) { - ctx.health?.markResolved(); - } - }; - work.then(onSettled, onSettled); - } + const work = ctx.semaphore.runExclusive(() => ctx.classifier.classify(buffer), controller.signal); + // タイムアウトが先に解決した場合に work の遅延 rejection が unhandled にならないよう握りつぶす。 + work.catch(() => undefined); + // work が(裏で)最終的に解決/失敗したら、記録した「詰まり」を戻して自己回復させる。 + // 永続 hung の場合はここに到達しないため、saturated が立ったまま /health が 503 を返し続ける。 + if (ctx.health) { + const onSettled = (): void => { + if (markedStuck) { + ctx.health?.markResolved(); + } + }; + work.then(onSettled, onSettled); + } - try { - const result = await Promise.race([work, timeout]); - if (result.ok) { - return { success: true, result: { predictions: result.predictions } }; - } - return toFailedResult(result.code); - } catch (err) { - return normalizeUnknownError(err); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } + try { + const result = await Promise.race([work, timeout]); + if (result.ok) { + return { success: true, result: { predictions: result.predictions } }; + } + return toFailedResult(result.code); + } catch (err) { + return normalizeUnknownError(err); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 595b036..5ac450b 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -4,30 +4,30 @@ import type { DetectErrorCode, DetectFailedResult } from './types.js'; * 分類済みの検出エラー。`code` で失敗種別を保持する。 */ export class DetectError extends Error { - readonly code: DetectErrorCode; + readonly code: DetectErrorCode; - constructor(code: DetectErrorCode, message?: string) { - super(message ?? code); - this.name = 'DetectError'; - this.code = code; - } + constructor(code: DetectErrorCode, message?: string) { + super(message ?? code); + this.name = 'DetectError'; + this.code = code; + } } export function isDetectError(value: unknown): value is DetectError { - return value instanceof DetectError; + return value instanceof DetectError; } export function toFailedResult(code: DetectErrorCode, message?: string): DetectFailedResult { - return { success: false, error: { code, message: message ?? code } }; + return { success: false, error: { code, message: message ?? code } }; } /** * 未分類の例外を `DETECTION_FAILED` に畳む。`DetectError` はその code を保つ。 */ export function normalizeUnknownError(value: unknown): DetectFailedResult { - if (isDetectError(value)) { - return toFailedResult(value.code, value.message); - } - const message = value instanceof Error ? value.message : String(value); - return toFailedResult('DETECTION_FAILED', message); + if (isDetectError(value)) { + return toFailedResult(value.code, value.message); + } + const message = value instanceof Error ? value.message : String(value); + return toFailedResult('DETECTION_FAILED', message); } diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index 566bea9..017e9c0 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -10,37 +10,37 @@ * 実際に発火できるようにする)。詰まった推論が裏で完了/失敗すれば件数は戻り、自己回復する。 */ export class InferenceHealth { - private stuck = 0; - private readonly maxConcurrent: number; + private stuck = 0; + private readonly maxConcurrent: number; - constructor(maxConcurrent: number) { - this.maxConcurrent = maxConcurrent; - } + constructor(maxConcurrent: number) { + this.maxConcurrent = maxConcurrent; + } - /** タイムアウト後もスロットを保持したままの推論を 1 件記録する。 */ - markStuck(): void { - this.stuck += 1; - } + /** タイムアウト後もスロットを保持したままの推論を 1 件記録する。 */ + markStuck(): void { + this.stuck += 1; + } - /** 詰まっていた推論が(裏で)完了/失敗したら戻す。 */ - markResolved(): void { - if (this.stuck > 0) { - this.stuck -= 1; - } - } + /** 詰まっていた推論が(裏で)完了/失敗したら戻す。 */ + markResolved(): void { + if (this.stuck > 0) { + this.stuck -= 1; + } + } - /** 現在「詰まっている」スロット数(診断用)。 */ - get stuckCount(): number { - return this.stuck; - } + /** 現在「詰まっている」スロット数(診断用)。 */ + get stuckCount(): number { + return this.stuck; + } - /** + /** * 全スロットが詰まっており、新規推論を受けられない状態か。 * `true` の間は `/health` を 503 にして、orchestrator の liveness probe による再起動を促す。 */ - get saturated(): boolean { - return this.stuck >= this.maxConcurrent; - } + get saturated(): boolean { + return this.stuck >= this.maxConcurrent; + } } /** @@ -49,31 +49,31 @@ export class InferenceHealth { * 一過性のサチュレーション(待機キュー abort 等の極短時間)で誤って exit しないよう、連続観測で確証を取る。 */ export class SaturationMonitor { - private consecutive = 0; - private readonly threshold: number; + private consecutive = 0; + private readonly threshold: number; - constructor(threshold: number) { - if (!Number.isInteger(threshold) || threshold < 1) { - throw new RangeError(`SaturationMonitor threshold must be a positive integer, got ${threshold}`); - } - this.threshold = threshold; - } + constructor(threshold: number) { + if (!Number.isInteger(threshold) || threshold < 1) { + throw new RangeError(`SaturationMonitor threshold must be a positive integer, got ${threshold}`); + } + this.threshold = threshold; + } - /** + /** * 1 回分の観測を反映する。saturated が threshold 回連続したら true(= exit すべき)。 * saturated でなければ連続カウントを 0 に戻す(自己回復)。 */ - observe(saturated: boolean): boolean { - if (!saturated) { - this.consecutive = 0; - return false; - } - this.consecutive += 1; - return this.consecutive >= this.threshold; - } + observe(saturated: boolean): boolean { + if (!saturated) { + this.consecutive = 0; + return false; + } + this.consecutive += 1; + return this.consecutive >= this.threshold; + } - /** 現在の連続 saturated 観測回数(ログ・診断用)。 */ - get consecutiveCount(): number { - return this.consecutive; - } + /** 現在の連続 saturated 観測回数(ログ・診断用)。 */ + get consecutiveCount(): number { + return this.consecutive; + } } diff --git a/packages/core/src/semaphore.ts b/packages/core/src/semaphore.ts index 8d66d04..5aa30f9 100644 --- a/packages/core/src/semaphore.ts +++ b/packages/core/src/semaphore.ts @@ -1,13 +1,13 @@ type Waiter = { - resolve: (release: () => void) => void; - reject: (reason: unknown) => void; - signal?: AbortSignal; - abortListener?: () => void; + resolve: (release: () => void) => void; + reject: (reason: unknown) => void; + signal?: AbortSignal; + abortListener?: () => void; }; function abortReason(signal: AbortSignal): unknown { - // AbortController#abort() はデフォルトで AbortError の DOMException を reason に設定する。 - return signal.reason ?? new Error('Aborted'); + // AbortController#abort() はデフォルトで AbortError の DOMException を reason に設定する。 + return signal.reason ?? new Error('Aborted'); } /** @@ -15,88 +15,88 @@ function abortReason(signal: AbortSignal): unknown { * 待機中に AbortSignal が発火したらキューから除去して reject する。 */ export class Semaphore { - private readonly max: number; - private active = 0; - private readonly waiters: Waiter[] = []; + private readonly max: number; + private active = 0; + private readonly waiters: Waiter[] = []; - constructor(max: number) { - if (!Number.isInteger(max) || max < 1) { - throw new RangeError(`Semaphore concurrency must be a positive integer, got ${max}`); - } - this.max = max; - } + constructor(max: number) { + if (!Number.isInteger(max) || max < 1) { + throw new RangeError(`Semaphore concurrency must be a positive integer, got ${max}`); + } + this.max = max; + } - /** 現在の同時実行数(テスト・診断用)。 */ - get activeCount(): number { - return this.active; - } + /** 現在の同時実行数(テスト・診断用)。 */ + get activeCount(): number { + return this.active; + } - /** 待機中の件数(テスト・診断用)。 */ - get pendingCount(): number { - return this.waiters.length; - } + /** 待機中の件数(テスト・診断用)。 */ + get pendingCount(): number { + return this.waiters.length; + } - /** + /** * スロットを 1 つ取得する。解決値は「解放関数」。必ず一度だけ呼ぶこと(多重呼び出しは無視される)。 */ - acquire(signal?: AbortSignal): Promise<() => void> { - if (signal?.aborted) { - return Promise.reject(abortReason(signal)); - } - if (this.active < this.max) { - this.active += 1; - return Promise.resolve(this.createRelease()); - } - return new Promise<() => void>((resolve, reject) => { - const waiter: Waiter = { resolve, reject, signal }; - if (signal) { - waiter.abortListener = () => { - const index = this.waiters.indexOf(waiter); - if (index >= 0) { - this.waiters.splice(index, 1); - reject(abortReason(signal)); - } - }; - signal.addEventListener('abort', waiter.abortListener, { once: true }); - } - this.waiters.push(waiter); - }); - } + acquire(signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) { + return Promise.reject(abortReason(signal)); + } + if (this.active < this.max) { + this.active += 1; + return Promise.resolve(this.createRelease()); + } + return new Promise<() => void>((resolve, reject) => { + const waiter: Waiter = { resolve, reject, signal }; + if (signal) { + waiter.abortListener = () => { + const index = this.waiters.indexOf(waiter); + if (index >= 0) { + this.waiters.splice(index, 1); + reject(abortReason(signal)); + } + }; + signal.addEventListener('abort', waiter.abortListener, { once: true }); + } + this.waiters.push(waiter); + }); + } - /** + /** * スロットを取得して `fn` を実行し、完了/失敗のいずれでも解放する。 */ - async runExclusive(fn: () => Promise | T, signal?: AbortSignal): Promise { - const release = await this.acquire(signal); - try { - return await fn(); - } finally { - release(); - } - } + async runExclusive(fn: () => Promise | T, signal?: AbortSignal): Promise { + const release = await this.acquire(signal); + try { + return await fn(); + } finally { + release(); + } + } - private createRelease(): () => void { - let released = false; - return () => { - if (released) { - return; - } - released = true; - this.handOffSlot(); - }; - } + private createRelease(): () => void { + let released = false; + return () => { + if (released) { + return; + } + released = true; + this.handOffSlot(); + }; + } - /** 解放されたスロットを次の待機者へ引き継ぐ。待機者がいなければ active を減らす。 */ - private handOffSlot(): void { - const waiter = this.waiters.shift(); - if (!waiter) { - this.active -= 1; - return; - } - if (waiter.signal && waiter.abortListener) { - waiter.signal.removeEventListener('abort', waiter.abortListener); - } - // active は引き継ぐため変化させない。 - waiter.resolve(this.createRelease()); - } + /** 解放されたスロットを次の待機者へ引き継ぐ。待機者がいなければ active を減らす。 */ + private handOffSlot(): void { + const waiter = this.waiters.shift(); + if (!waiter) { + this.active -= 1; + return; + } + if (waiter.signal && waiter.abortListener) { + waiter.signal.removeEventListener('abort', waiter.abortListener); + } + // active は引き継ぐため変化させない。 + waiter.resolve(this.createRelease()); + } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 442eb8a..227cf03 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,25 +15,25 @@ export type DetectErrorCode = * className を `string` に緩めて「Misskey が任意のクラス名を find できる」契約を表す。 */ export type Prediction = { - className: string; - probability: number; + className: string; + probability: number; }; export type DetectImageSuccessResult = { - success: true; - result: { - // 全クラスの生出力(確率降順)をそのまま返す。 - predictions: Prediction[]; - }; + success: true; + result: { + // 全クラスの生出力(確率降順)をそのまま返す。 + predictions: Prediction[]; + }; }; export type DetectFailedResult = { - success: false; - error: { - code: DetectErrorCode; - // 人間向けの診断テキスト。API 契約上の意味は持たない(呼び出し元はコードのみで分岐する)。 - message: string; - }; + success: false; + error: { + code: DetectErrorCode; + // 人間向けの診断テキスト。API 契約上の意味は持たない(呼び出し元はコードのみで分岐する)。 + message: string; + }; }; export type DetectImageResult = DetectImageSuccessResult | DetectFailedResult; diff --git a/packages/core/test/classifier.integration.test.ts b/packages/core/test/classifier.integration.test.ts index 928ebb6..cfc1cce 100644 --- a/packages/core/test/classifier.integration.test.ts +++ b/packages/core/test/classifier.integration.test.ts @@ -8,43 +8,43 @@ const NSFW_CLASS_NAMES = ['Drawing', 'Hentai', 'Neutral', 'Porn', 'Sexy']; const d = (await integrationEnabled()) ? describe : describe.skip; d('classifier (real ONNX model)', () => { - let classifier: Classifier; - - beforeAll(async () => { - // 実モデルをファイルパスでロードする(ORT は file:// URL ではなくパスを受け取る)。 - classifier = await createClassifier(TEST_MODEL_DIR); - }); - - it('loads the model and reports availability', () => { - expect(classifier.available).toBe(true); - }); - - it('classifies a normalized 299x299 PNG into the five NSFW classes', async () => { - const png = encodeRgbPng(299, 299, [119, 119, 119]); - const result = await classifier.classify(png); - - expect(result.ok).toBe(true); - if (!result.ok) { - return; - } - expect(result.predictions).toHaveLength(5); - expect([...result.predictions.map((p) => p.className)].sort()).toEqual(NSFW_CLASS_NAMES); - for (const prediction of result.predictions) { - expect(prediction.probability).toBeGreaterThanOrEqual(0); - expect(prediction.probability).toBeLessThanOrEqual(1); - } - const sum = result.predictions.reduce((acc, p) => acc + p.probability, 0); - expect(sum).toBeCloseTo(1, 1); - }); - - it('returns IMAGE_DECODE_FAILED for corrupt (non-image) bytes', async () => { - const result = await classifier.classify(Buffer.from('this is definitely not an image')); - expect(result).toEqual({ ok: false, code: 'IMAGE_DECODE_FAILED' }); - }); - - it('returns IMAGE_DECODE_FAILED for a PNG with wrong dimensions', async () => { - const png = encodeRgbPng(100, 100, [119, 119, 119]); - const result = await classifier.classify(png); - expect(result).toEqual({ ok: false, code: 'IMAGE_DECODE_FAILED' }); - }); + let classifier: Classifier; + + beforeAll(async () => { + // 実モデルをファイルパスでロードする(ORT は file:// URL ではなくパスを受け取る)。 + classifier = await createClassifier(TEST_MODEL_DIR); + }); + + it('loads the model and reports availability', () => { + expect(classifier.available).toBe(true); + }); + + it('classifies a normalized 299x299 PNG into the five NSFW classes', async () => { + const png = encodeRgbPng(299, 299, [119, 119, 119]); + const result = await classifier.classify(png); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.predictions).toHaveLength(5); + expect(result.predictions.map((p) => p.className).sort()).toEqual(NSFW_CLASS_NAMES); + for (const prediction of result.predictions) { + expect(prediction.probability).toBeGreaterThanOrEqual(0); + expect(prediction.probability).toBeLessThanOrEqual(1); + } + const sum = result.predictions.reduce((acc, p) => acc + p.probability, 0); + expect(sum).toBeCloseTo(1, 1); + }); + + it('returns IMAGE_DECODE_FAILED for corrupt (non-image) bytes', async () => { + const result = await classifier.classify(Buffer.from('this is definitely not an image')); + expect(result).toEqual({ ok: false, code: 'IMAGE_DECODE_FAILED' }); + }); + + it('returns IMAGE_DECODE_FAILED for a PNG with wrong dimensions', async () => { + const png = encodeRgbPng(100, 100, [119, 119, 119]); + const result = await classifier.classify(png); + expect(result).toEqual({ ok: false, code: 'IMAGE_DECODE_FAILED' }); + }); }); diff --git a/packages/core/test/classifier.test.ts b/packages/core/test/classifier.test.ts index df6a042..8c05cc3 100644 --- a/packages/core/test/classifier.test.ts +++ b/packages/core/test/classifier.test.ts @@ -2,22 +2,22 @@ import { describe, expect, it } from 'vitest'; import { createClassifier } from '../src/classifier.js'; describe('classifier', () => { - it('returns MODEL_UNAVAILABLE when model directory does not exist', async () => { - const classifier = await createClassifier('/nonexistent/path/', { - arch: 'x64', - }); - expect(classifier.available).toBe(false); - const result = await classifier.classify(Buffer.from('test')); - expect(result).toEqual({ ok: false, code: 'MODEL_UNAVAILABLE' }); - }); + it('returns MODEL_UNAVAILABLE when model directory does not exist', async () => { + const classifier = await createClassifier('/nonexistent/path/', { + arch: 'x64', + }); + expect(classifier.available).toBe(false); + const result = await classifier.classify(Buffer.from('test')); + expect(result).toEqual({ ok: false, code: 'MODEL_UNAVAILABLE' }); + }); - it('returns IMAGE_DECODE_FAILED for non-PNG bytes', async () => { - // createClassifier with a valid model is integration-test territory. - // This test verifies the unavailable path only. - const classifier = await createClassifier('/nonexistent/path/', { - arch: 'x64', - }); - const result = await classifier.classify(Buffer.from('not an image')); - expect(result).toEqual({ ok: false, code: 'MODEL_UNAVAILABLE' }); - }); + it('returns IMAGE_DECODE_FAILED for non-PNG bytes', async () => { + // createClassifier with a valid model is integration-test territory. + // This test verifies the unavailable path only. + const classifier = await createClassifier('/nonexistent/path/', { + arch: 'x64', + }); + const result = await classifier.classify(Buffer.from('not an image')); + expect(result).toEqual({ ok: false, code: 'MODEL_UNAVAILABLE' }); + }); }); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index f0d2754..01c7ef2 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -1,167 +1,167 @@ import { describe, expect, it } from 'vitest'; import { - type Config, - ConfigError, - DEFAULT_MAX_BINARY_SIZE, - DEFAULT_MAX_BODY_SIZE, - DEFAULT_MAX_CONCURRENT_JOBS, - DEFAULT_MAX_IMAGE_HEIGHT, - DEFAULT_MAX_IMAGE_PIXELS, - DEFAULT_MAX_IMAGE_WIDTH, - DEFAULT_MAX_PARTS, - DEFAULT_REQUEST_TIMEOUT_MS, - unknownConfigKeys, - validateConfig, + type Config, + ConfigError, + DEFAULT_MAX_BINARY_SIZE, + DEFAULT_MAX_BODY_SIZE, + DEFAULT_MAX_CONCURRENT_JOBS, + DEFAULT_MAX_IMAGE_HEIGHT, + DEFAULT_MAX_IMAGE_PIXELS, + DEFAULT_MAX_IMAGE_WIDTH, + DEFAULT_MAX_PARTS, + DEFAULT_REQUEST_TIMEOUT_MS, + unknownConfigKeys, + validateConfig, } from '../src/config.js'; const base: Config = { port: 3000, modelDir: '/models/nsfw', apiKey: 'secret' }; describe('validateConfig', () => { - it('applies defaults for a minimal valid config (port + modelDir + apiKey)', () => { - const resolved = validateConfig({ ...base }); - expect(resolved.listen).toEqual({ kind: 'port', port: 3000, host: '127.0.0.1' }); - expect(resolved.modelDir).toBe('/models/nsfw/'); // trailing slash appended - expect(resolved.apiKey).toBe('secret'); - expect(resolved.allowUnauthenticatedTcp).toBe(false); - expect(resolved.maxBinarySize).toBe(DEFAULT_MAX_BINARY_SIZE); - expect(resolved.maxImageWidth).toBe(DEFAULT_MAX_IMAGE_WIDTH); - expect(resolved.maxImageHeight).toBe(DEFAULT_MAX_IMAGE_HEIGHT); - expect(resolved.maxImagePixels).toBe(DEFAULT_MAX_IMAGE_PIXELS); - expect(resolved.maxConcurrentJobs).toBe(DEFAULT_MAX_CONCURRENT_JOBS); - expect(resolved.requestTimeoutMs).toBe(DEFAULT_REQUEST_TIMEOUT_MS); - }); - - it('preserves an already trailing-slashed modelDir', () => { - expect(validateConfig({ ...base, modelDir: '/models/nsfw/' }).modelDir).toBe('/models/nsfw/'); - }); - - it('accepts socket instead of port', () => { - const resolved = validateConfig({ socket: '/tmp/sd.sock', modelDir: '/m' }); - expect(resolved.listen).toEqual({ kind: 'socket', socket: '/tmp/sd.sock' }); - }); - - it('rejects a non-object config', () => { - expect(() => validateConfig(null)).toThrow(ConfigError); - expect(() => validateConfig('x')).toThrow(ConfigError); - expect(() => validateConfig([])).toThrow(ConfigError); - }); - - it('requires modelDir', () => { - expect(() => validateConfig({ port: 3000 })).toThrow(ConfigError); - expect(() => validateConfig({ port: 3000, modelDir: '' })).toThrow(ConfigError); - }); - - it('requires exactly one of port or socket', () => { - expect(() => validateConfig({ modelDir: '/m' })).toThrow(/exactly one of port or socket/); - expect(() => validateConfig({ port: 3000, socket: '/s', modelDir: '/m' })).toThrow(/exactly one of port or socket/); - }); - - it('validates port range and type', () => { - expect(() => validateConfig({ port: 0, modelDir: '/m' })).toThrow(ConfigError); - expect(() => validateConfig({ port: 70000, modelDir: '/m' })).toThrow(ConfigError); - expect(() => validateConfig({ port: 3000.5, modelDir: '/m' })).toThrow(ConfigError); - expect(() => validateConfig({ port: '3000' as unknown as number, modelDir: '/m' })).toThrow(ConfigError); - }); - - it('rejects an empty socket', () => { - expect(() => validateConfig({ socket: '', modelDir: '/m' })).toThrow(ConfigError); - }); - - it('validates apiKey when present', () => { - expect(validateConfig({ ...base, apiKey: 'secret' }).apiKey).toBe('secret'); - expect(() => validateConfig({ ...base, apiKey: '' })).toThrow(ConfigError); - expect(() => validateConfig({ ...base, apiKey: 123 as unknown as string })).toThrow(ConfigError); - }); - - it('requires explicit opt-in for unauthenticated TCP listeners', () => { - expect(() => validateConfig({ port: 3000, modelDir: '/m' })).toThrow(/apiKey/); - - const resolved = validateConfig({ port: 3000, modelDir: '/m', allowUnauthenticatedTcp: true }); - expect(resolved.listen).toEqual({ kind: 'port', port: 3000, host: '127.0.0.1' }); - expect(resolved.apiKey).toBeUndefined(); - expect(resolved.allowUnauthenticatedTcp).toBe(true); - }); - - it('allows socket listeners without apiKey by default', () => { - const resolved = validateConfig({ socket: '/tmp/sd.sock', modelDir: '/m' }); - expect(resolved.listen).toEqual({ kind: 'socket', socket: '/tmp/sd.sock' }); - expect(resolved.apiKey).toBeUndefined(); - expect(resolved.allowUnauthenticatedTcp).toBe(false); - }); - - it('validates numeric fields as positive integers', () => { - expect(validateConfig({ ...base, maxBinarySize: 2048 }).maxBinarySize).toBe(2048); - expect(validateConfig({ ...base, maxImageWidth: 512 }).maxImageWidth).toBe(512); - expect(validateConfig({ ...base, maxImageHeight: 512 }).maxImageHeight).toBe(512); - expect(validateConfig({ ...base, maxImagePixels: 512 * 512 }).maxImagePixels).toBe(512 * 512); - expect(validateConfig({ ...base, maxConcurrentJobs: 4 }).maxConcurrentJobs).toBe(4); - expect(validateConfig({ ...base, requestTimeoutMs: 1000 }).requestTimeoutMs).toBe(1000); - expect(validateConfig({ ...base, maxParts: 5 }).maxParts).toBe(5); - expect(validateConfig({ ...base, maxBodySize: 1_048_576 }).maxBodySize).toBe(1_048_576); - for (const field of [ - 'maxBinarySize', - 'maxImageWidth', - 'maxImageHeight', - 'maxImagePixels', - 'maxConcurrentJobs', - 'requestTimeoutMs', - 'maxParts', - 'maxBodySize', - ] as const) { - expect(() => validateConfig({ ...base, [field]: 0 })).toThrow(ConfigError); - expect(() => validateConfig({ ...base, [field]: -1 })).toThrow(ConfigError); - expect(() => validateConfig({ ...base, [field]: 1.5 })).toThrow(ConfigError); - } - }); - - it('applies defaults for maxParts and maxBodySize', () => { - const resolved = validateConfig({ ...base }); - expect(resolved.maxParts).toBe(DEFAULT_MAX_PARTS); - expect(resolved.maxBodySize).toBe(DEFAULT_MAX_BODY_SIZE); - }); - - it('defaults host to 127.0.0.1 and accepts an explicit host for port listeners', () => { - expect(validateConfig({ ...base }).listen).toEqual({ kind: 'port', port: 3000, host: '127.0.0.1' }); - expect(validateConfig({ ...base, host: '0.0.0.0' }).listen).toEqual({ - kind: 'port', - port: 3000, - host: '0.0.0.0', - }); - }); - - it('rejects an invalid host', () => { - expect(() => validateConfig({ ...base, host: '' })).toThrow(ConfigError); - expect(() => validateConfig({ ...base, host: 123 as unknown as string })).toThrow(ConfigError); - }); - - it('rejects host together with a socket listener', () => { - expect(() => validateConfig({ socket: '/tmp/sd.sock', modelDir: '/m', host: '0.0.0.0' })).toThrow( - /host is only valid with a port/, - ); - }); - - it('rejects unsafe-integer numeric fields', () => { - expect(() => validateConfig({ ...base, maxBodySize: Number.MAX_SAFE_INTEGER + 1 })).toThrow(ConfigError); - expect(() => validateConfig({ ...base, maxBinarySize: Number.POSITIVE_INFINITY })).toThrow(ConfigError); - }); - - it('rejects requestTimeoutMs above the setTimeout 32-bit limit (would fire immediately)', () => { - expect(validateConfig({ ...base, requestTimeoutMs: 2_147_483_647 }).requestTimeoutMs).toBe(2_147_483_647); - expect(() => validateConfig({ ...base, requestTimeoutMs: 2_147_483_648 })).toThrow(/≤ 2147483647/); - }); + it('applies defaults for a minimal valid config (port + modelDir + apiKey)', () => { + const resolved = validateConfig({ ...base }); + expect(resolved.listen).toEqual({ kind: 'port', port: 3000, host: '127.0.0.1' }); + expect(resolved.modelDir).toBe('/models/nsfw/'); // trailing slash appended + expect(resolved.apiKey).toBe('secret'); + expect(resolved.allowUnauthenticatedTcp).toBe(false); + expect(resolved.maxBinarySize).toBe(DEFAULT_MAX_BINARY_SIZE); + expect(resolved.maxImageWidth).toBe(DEFAULT_MAX_IMAGE_WIDTH); + expect(resolved.maxImageHeight).toBe(DEFAULT_MAX_IMAGE_HEIGHT); + expect(resolved.maxImagePixels).toBe(DEFAULT_MAX_IMAGE_PIXELS); + expect(resolved.maxConcurrentJobs).toBe(DEFAULT_MAX_CONCURRENT_JOBS); + expect(resolved.requestTimeoutMs).toBe(DEFAULT_REQUEST_TIMEOUT_MS); + }); + + it('preserves an already trailing-slashed modelDir', () => { + expect(validateConfig({ ...base, modelDir: '/models/nsfw/' }).modelDir).toBe('/models/nsfw/'); + }); + + it('accepts socket instead of port', () => { + const resolved = validateConfig({ socket: '/tmp/sd.sock', modelDir: '/m' }); + expect(resolved.listen).toEqual({ kind: 'socket', socket: '/tmp/sd.sock' }); + }); + + it('rejects a non-object config', () => { + expect(() => validateConfig(null)).toThrow(ConfigError); + expect(() => validateConfig('x')).toThrow(ConfigError); + expect(() => validateConfig([])).toThrow(ConfigError); + }); + + it('requires modelDir', () => { + expect(() => validateConfig({ port: 3000 })).toThrow(ConfigError); + expect(() => validateConfig({ port: 3000, modelDir: '' })).toThrow(ConfigError); + }); + + it('requires exactly one of port or socket', () => { + expect(() => validateConfig({ modelDir: '/m' })).toThrow(/exactly one of port or socket/); + expect(() => validateConfig({ port: 3000, socket: '/s', modelDir: '/m' })).toThrow(/exactly one of port or socket/); + }); + + it('validates port range and type', () => { + expect(() => validateConfig({ port: 0, modelDir: '/m' })).toThrow(ConfigError); + expect(() => validateConfig({ port: 70000, modelDir: '/m' })).toThrow(ConfigError); + expect(() => validateConfig({ port: 3000.5, modelDir: '/m' })).toThrow(ConfigError); + expect(() => validateConfig({ port: '3000' as unknown as number, modelDir: '/m' })).toThrow(ConfigError); + }); + + it('rejects an empty socket', () => { + expect(() => validateConfig({ socket: '', modelDir: '/m' })).toThrow(ConfigError); + }); + + it('validates apiKey when present', () => { + expect(validateConfig({ ...base, apiKey: 'secret' }).apiKey).toBe('secret'); + expect(() => validateConfig({ ...base, apiKey: '' })).toThrow(ConfigError); + expect(() => validateConfig({ ...base, apiKey: 123 as unknown as string })).toThrow(ConfigError); + }); + + it('requires explicit opt-in for unauthenticated TCP listeners', () => { + expect(() => validateConfig({ port: 3000, modelDir: '/m' })).toThrow(/apiKey/); + + const resolved = validateConfig({ port: 3000, modelDir: '/m', allowUnauthenticatedTcp: true }); + expect(resolved.listen).toEqual({ kind: 'port', port: 3000, host: '127.0.0.1' }); + expect(resolved.apiKey).toBeUndefined(); + expect(resolved.allowUnauthenticatedTcp).toBe(true); + }); + + it('allows socket listeners without apiKey by default', () => { + const resolved = validateConfig({ socket: '/tmp/sd.sock', modelDir: '/m' }); + expect(resolved.listen).toEqual({ kind: 'socket', socket: '/tmp/sd.sock' }); + expect(resolved.apiKey).toBeUndefined(); + expect(resolved.allowUnauthenticatedTcp).toBe(false); + }); + + it('validates numeric fields as positive integers', () => { + expect(validateConfig({ ...base, maxBinarySize: 2048 }).maxBinarySize).toBe(2048); + expect(validateConfig({ ...base, maxImageWidth: 512 }).maxImageWidth).toBe(512); + expect(validateConfig({ ...base, maxImageHeight: 512 }).maxImageHeight).toBe(512); + expect(validateConfig({ ...base, maxImagePixels: 512 * 512 }).maxImagePixels).toBe(512 * 512); + expect(validateConfig({ ...base, maxConcurrentJobs: 4 }).maxConcurrentJobs).toBe(4); + expect(validateConfig({ ...base, requestTimeoutMs: 1000 }).requestTimeoutMs).toBe(1000); + expect(validateConfig({ ...base, maxParts: 5 }).maxParts).toBe(5); + expect(validateConfig({ ...base, maxBodySize: 1_048_576 }).maxBodySize).toBe(1_048_576); + for (const field of [ + 'maxBinarySize', + 'maxImageWidth', + 'maxImageHeight', + 'maxImagePixels', + 'maxConcurrentJobs', + 'requestTimeoutMs', + 'maxParts', + 'maxBodySize', + ] as const) { + expect(() => validateConfig({ ...base, [field]: 0 })).toThrow(ConfigError); + expect(() => validateConfig({ ...base, [field]: -1 })).toThrow(ConfigError); + expect(() => validateConfig({ ...base, [field]: 1.5 })).toThrow(ConfigError); + } + }); + + it('applies defaults for maxParts and maxBodySize', () => { + const resolved = validateConfig({ ...base }); + expect(resolved.maxParts).toBe(DEFAULT_MAX_PARTS); + expect(resolved.maxBodySize).toBe(DEFAULT_MAX_BODY_SIZE); + }); + + it('defaults host to 127.0.0.1 and accepts an explicit host for port listeners', () => { + expect(validateConfig({ ...base }).listen).toEqual({ kind: 'port', port: 3000, host: '127.0.0.1' }); + expect(validateConfig({ ...base, host: '0.0.0.0' }).listen).toEqual({ + kind: 'port', + port: 3000, + host: '0.0.0.0', + }); + }); + + it('rejects an invalid host', () => { + expect(() => validateConfig({ ...base, host: '' })).toThrow(ConfigError); + expect(() => validateConfig({ ...base, host: 123 as unknown as string })).toThrow(ConfigError); + }); + + it('rejects host together with a socket listener', () => { + expect(() => validateConfig({ socket: '/tmp/sd.sock', modelDir: '/m', host: '0.0.0.0' })).toThrow( + /host is only valid with a port/, + ); + }); + + it('rejects unsafe-integer numeric fields', () => { + expect(() => validateConfig({ ...base, maxBodySize: Number.MAX_SAFE_INTEGER + 1 })).toThrow(ConfigError); + expect(() => validateConfig({ ...base, maxBinarySize: Number.POSITIVE_INFINITY })).toThrow(ConfigError); + }); + + it('rejects requestTimeoutMs above the setTimeout 32-bit limit (would fire immediately)', () => { + expect(validateConfig({ ...base, requestTimeoutMs: 2_147_483_647 }).requestTimeoutMs).toBe(2_147_483_647); + expect(() => validateConfig({ ...base, requestTimeoutMs: 2_147_483_648 })).toThrow(/≤ 2147483647/); + }); }); describe('unknownConfigKeys', () => { - it('returns typo / unknown top-level keys', () => { - expect(unknownConfigKeys({ ...base, maxBinarySie: 1 })).toEqual(['maxBinarySie']); - }); - - it('returns an empty array for a config with only known keys', () => { - expect(unknownConfigKeys({ ...base, host: '0.0.0.0', maxParts: 5 })).toEqual([]); - }); - - it('returns an empty array for a non-object (validateConfig handles the error)', () => { - expect(unknownConfigKeys(null)).toEqual([]); - expect(unknownConfigKeys('nope')).toEqual([]); - }); + it('returns typo / unknown top-level keys', () => { + expect(unknownConfigKeys({ ...base, maxBinarySie: 1 })).toEqual(['maxBinarySie']); + }); + + it('returns an empty array for a config with only known keys', () => { + expect(unknownConfigKeys({ ...base, host: '0.0.0.0', maxParts: 5 })).toEqual([]); + }); + + it('returns an empty array for a non-object (validateConfig handles the error)', () => { + expect(unknownConfigKeys(null)).toEqual([]); + expect(unknownConfigKeys('nope')).toEqual([]); + }); }); diff --git a/packages/core/test/cpu-support.test.ts b/packages/core/test/cpu-support.test.ts index 3fc4ba6..140f40d 100644 --- a/packages/core/test/cpu-support.test.ts +++ b/packages/core/test/cpu-support.test.ts @@ -2,16 +2,16 @@ import { describe, expect, it } from 'vitest'; import { computeIsSupportedCpu } from '../src/classifier.js'; describe('computeIsSupportedCpu', () => { - it('returns true on x64', async () => { - expect(await computeIsSupportedCpu('x64')).toBe(true); - }); + it('returns true on x64', async () => { + expect(await computeIsSupportedCpu('x64')).toBe(true); + }); - it('returns true on arm64', async () => { - expect(await computeIsSupportedCpu('arm64')).toBe(true); - }); + it('returns true on arm64', async () => { + expect(await computeIsSupportedCpu('arm64')).toBe(true); + }); - it('returns false on unsupported architectures', async () => { - expect(await computeIsSupportedCpu('ia32')).toBe(false); - expect(await computeIsSupportedCpu('ppc64')).toBe(false); - }); + it('returns false on unsupported architectures', async () => { + expect(await computeIsSupportedCpu('ia32')).toBe(false); + expect(await computeIsSupportedCpu('ppc64')).toBe(false); + }); }); diff --git a/packages/core/test/detect.test.ts b/packages/core/test/detect.test.ts index 10c9641..228d21e 100644 --- a/packages/core/test/detect.test.ts +++ b/packages/core/test/detect.test.ts @@ -7,150 +7,150 @@ import { Semaphore } from '../src/semaphore.js'; const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); function classifierReturning(result: ClassifyResult): Classifier { - return { available: true, classify: () => Promise.resolve(result) }; + return { available: true, classify: () => Promise.resolve(result) }; } const buffer = Buffer.from('not-a-real-image'); describe('detectImage', () => { - it('returns success with raw predictions when classify succeeds', async () => { - const predictions = [ - { className: 'Neutral' as const, probability: 0.9 }, - { className: 'Porn' as const, probability: 0.1 }, - ]; - const ctx = { - classifier: classifierReturning({ ok: true, predictions }), - semaphore: new Semaphore(2), - requestTimeoutMs: 1000, - }; - const result = await detectImage(buffer, ctx); - expect(result).toEqual({ success: true, result: { predictions } }); - }); - - it('maps each classifier error code to a failed result', async () => { - for (const code of ['IMAGE_DECODE_FAILED', 'DETECTION_FAILED', 'MODEL_UNAVAILABLE'] as const) { - const ctx = { - classifier: classifierReturning({ ok: false, code }), - semaphore: new Semaphore(2), - requestTimeoutMs: 1000, - }; - const result = await detectImage(buffer, ctx); - expect(result).toEqual({ success: false, error: { code, message: code } }); - } - }); - - it('returns DETECTION_FAILED when a thrown error escapes classify', async () => { - const classifier: Classifier = { - available: true, - classify: () => Promise.reject(new Error('kaboom')), - }; - const result = await detectImage(buffer, { - classifier, - semaphore: new Semaphore(2), - requestTimeoutMs: 1000, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('DETECTION_FAILED'); - } - }); - - it('returns DETECTION_FAILED on timeout when classify never resolves', async () => { - const classifier: Classifier = { - available: true, - classify: () => new Promise(() => undefined), - }; - const result = await detectImage(buffer, { - classifier, - semaphore: new Semaphore(1), - requestTimeoutMs: 10, - }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.code).toBe('DETECTION_FAILED'); - } - }); - - it('marks inference health stuck on timeout and self-heals when classify later settles', async () => { - let resolveClassify: (r: ClassifyResult) => void = () => undefined; - const classifier: Classifier = { - available: true, - classify: () => - new Promise((resolve) => { - resolveClassify = resolve; - }), - }; - const health = new InferenceHealth(1); - const result = await detectImage(buffer, { - classifier, - semaphore: new Semaphore(1), - requestTimeoutMs: 10, - health, - }); - expect(result.success).toBe(false); - // タイムアウト後もスロットを保持=詰まり 1 件。容量 1 なので saturated。 - expect(health.stuckCount).toBe(1); - expect(health.saturated).toBe(true); - - // 裏で classify が完了したら詰まりが戻り、自己回復する。 - resolveClassify({ ok: true, predictions: [] }); - await flushMicrotasks(); - expect(health.stuckCount).toBe(0); - expect(health.saturated).toBe(false); - }); - - it('does not leak stuck count when a queued request is aborted on timeout', async () => { - // 容量 1。1 件目がスロットを掴んだまま hung、2 件目は queue で待機中にタイムアウト→abort される。 - // queue で abort された 2 件目は markStuck(+1)→work reject→markResolved(-1) で相殺し、リークしない。 - const semaphore = new Semaphore(1); - const health = new InferenceHealth(1); - const hung: Classifier = { - available: true, - classify: () => new Promise(() => undefined), - }; - const ctx = { classifier: hung, semaphore, requestTimeoutMs: 10, health }; - - const first = detectImage(buffer, ctx); // スロット取得(hung) - const second = detectImage(buffer, ctx); // queue 待機 → timeout で abort - - expect((await first).success).toBe(false); - expect((await second).success).toBe(false); - await flushMicrotasks(); - - // hung な 1 件目の分だけが残る。queue abort された 2 件目は相殺されてリークしない。 - expect(health.stuckCount).toBe(1); - }); - - it('does not exceed maxConcurrentJobs after timeout', async () => { - // classify が永遠に resolve しない hung 状態をシミュレートする。 - // タイムアウト後に semaphore の activeCount が maxConcurrentJobs を超えないことを検証する。 - let classifyCallCount = 0; - const semaphore = new Semaphore(1); - const classifier: Classifier = { - available: true, - classify: () => { - classifyCallCount++; - return new Promise(() => undefined); - }, - }; - const ctx = { classifier, semaphore, requestTimeoutMs: 10 }; - - // 1 件目: hung classify がタイムアウトしても slot を保持したまま。 - const result = await detectImage(buffer, ctx); - expect(result.success).toBe(false); - - // activeCount は 1 のまま(slot は hung classify が保持)。 - expect(semaphore.activeCount).toBe(1); - expect(classifyCallCount).toBe(1); - - // 2 件目をすぐに開始すると slot が空かないため pending に積まれる。 - // もし slot が timeout 時に強制返却されていれば classify が 2 回目に呼ばれ、 - // activeCount が 2 になって不変条件が破れる。 - void detectImage(buffer, ctx); - // マイクロタスクを消化させて pendingCount に反映させる。 - await Promise.resolve(); - expect(semaphore.pendingCount).toBe(1); - expect(semaphore.activeCount).toBe(1); - expect(classifyCallCount).toBe(1); - }); + it('returns success with raw predictions when classify succeeds', async () => { + const predictions = [ + { className: 'Neutral' as const, probability: 0.9 }, + { className: 'Porn' as const, probability: 0.1 }, + ]; + const ctx = { + classifier: classifierReturning({ ok: true, predictions }), + semaphore: new Semaphore(2), + requestTimeoutMs: 1000, + }; + const result = await detectImage(buffer, ctx); + expect(result).toEqual({ success: true, result: { predictions } }); + }); + + it('maps each classifier error code to a failed result', async () => { + for (const code of ['IMAGE_DECODE_FAILED', 'DETECTION_FAILED', 'MODEL_UNAVAILABLE'] as const) { + const ctx = { + classifier: classifierReturning({ ok: false, code }), + semaphore: new Semaphore(2), + requestTimeoutMs: 1000, + }; + const result = await detectImage(buffer, ctx); + expect(result).toEqual({ success: false, error: { code, message: code } }); + } + }); + + it('returns DETECTION_FAILED when a thrown error escapes classify', async () => { + const classifier: Classifier = { + available: true, + classify: () => Promise.reject(new Error('kaboom')), + }; + const result = await detectImage(buffer, { + classifier, + semaphore: new Semaphore(2), + requestTimeoutMs: 1000, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('DETECTION_FAILED'); + } + }); + + it('returns DETECTION_FAILED on timeout when classify never resolves', async () => { + const classifier: Classifier = { + available: true, + classify: () => new Promise(() => undefined), + }; + const result = await detectImage(buffer, { + classifier, + semaphore: new Semaphore(1), + requestTimeoutMs: 10, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('DETECTION_FAILED'); + } + }); + + it('marks inference health stuck on timeout and self-heals when classify later settles', async () => { + let resolveClassify: (r: ClassifyResult) => void = () => undefined; + const classifier: Classifier = { + available: true, + classify: () => + new Promise((resolve) => { + resolveClassify = resolve; + }), + }; + const health = new InferenceHealth(1); + const result = await detectImage(buffer, { + classifier, + semaphore: new Semaphore(1), + requestTimeoutMs: 10, + health, + }); + expect(result.success).toBe(false); + // タイムアウト後もスロットを保持=詰まり 1 件。容量 1 なので saturated。 + expect(health.stuckCount).toBe(1); + expect(health.saturated).toBe(true); + + // 裏で classify が完了したら詰まりが戻り、自己回復する。 + resolveClassify({ ok: true, predictions: [] }); + await flushMicrotasks(); + expect(health.stuckCount).toBe(0); + expect(health.saturated).toBe(false); + }); + + it('does not leak stuck count when a queued request is aborted on timeout', async () => { + // 容量 1。1 件目がスロットを掴んだまま hung、2 件目は queue で待機中にタイムアウト→abort される。 + // queue で abort された 2 件目は markStuck(+1)→work reject→markResolved(-1) で相殺し、リークしない。 + const semaphore = new Semaphore(1); + const health = new InferenceHealth(1); + const hung: Classifier = { + available: true, + classify: () => new Promise(() => undefined), + }; + const ctx = { classifier: hung, semaphore, requestTimeoutMs: 10, health }; + + const first = detectImage(buffer, ctx); // スロット取得(hung) + const second = detectImage(buffer, ctx); // queue 待機 → timeout で abort + + expect((await first).success).toBe(false); + expect((await second).success).toBe(false); + await flushMicrotasks(); + + // hung な 1 件目の分だけが残る。queue abort された 2 件目は相殺されてリークしない。 + expect(health.stuckCount).toBe(1); + }); + + it('does not exceed maxConcurrentJobs after timeout', async () => { + // classify が永遠に resolve しない hung 状態をシミュレートする。 + // タイムアウト後に semaphore の activeCount が maxConcurrentJobs を超えないことを検証する。 + let classifyCallCount = 0; + const semaphore = new Semaphore(1); + const classifier: Classifier = { + available: true, + classify: () => { + classifyCallCount++; + return new Promise(() => undefined); + }, + }; + const ctx = { classifier, semaphore, requestTimeoutMs: 10 }; + + // 1 件目: hung classify がタイムアウトしても slot を保持したまま。 + const result = await detectImage(buffer, ctx); + expect(result.success).toBe(false); + + // activeCount は 1 のまま(slot は hung classify が保持)。 + expect(semaphore.activeCount).toBe(1); + expect(classifyCallCount).toBe(1); + + // 2 件目をすぐに開始すると slot が空かないため pending に積まれる。 + // もし slot が timeout 時に強制返却されていれば classify が 2 回目に呼ばれ、 + // activeCount が 2 になって不変条件が破れる。 + void detectImage(buffer, ctx); + // マイクロタスクを消化させて pendingCount に反映させる。 + await Promise.resolve(); + expect(semaphore.pendingCount).toBe(1); + expect(semaphore.activeCount).toBe(1); + expect(classifyCallCount).toBe(1); + }); }); diff --git a/packages/core/test/health.test.ts b/packages/core/test/health.test.ts index 860d650..7d435f5 100644 --- a/packages/core/test/health.test.ts +++ b/packages/core/test/health.test.ts @@ -2,52 +2,52 @@ import { describe, expect, it } from 'vitest'; import { InferenceHealth, SaturationMonitor } from '../src/health.js'; describe('InferenceHealth', () => { - it('counts stuck slots and reports saturated at capacity', () => { - const h = new InferenceHealth(2); - expect(h.stuckCount).toBe(0); - expect(h.saturated).toBe(false); - - h.markStuck(); - expect(h.stuckCount).toBe(1); - expect(h.saturated).toBe(false); // 1/2 はまだ余力あり - - h.markStuck(); - expect(h.stuckCount).toBe(2); - expect(h.saturated).toBe(true); // 全スロット詰まり - - h.markResolved(); - expect(h.stuckCount).toBe(1); - expect(h.saturated).toBe(false); // 自己回復 - }); - - it('never decrements below zero', () => { - const h = new InferenceHealth(1); - h.markResolved(); - h.markResolved(); - expect(h.stuckCount).toBe(0); - }); + it('counts stuck slots and reports saturated at capacity', () => { + const h = new InferenceHealth(2); + expect(h.stuckCount).toBe(0); + expect(h.saturated).toBe(false); + + h.markStuck(); + expect(h.stuckCount).toBe(1); + expect(h.saturated).toBe(false); // 1/2 はまだ余力あり + + h.markStuck(); + expect(h.stuckCount).toBe(2); + expect(h.saturated).toBe(true); // 全スロット詰まり + + h.markResolved(); + expect(h.stuckCount).toBe(1); + expect(h.saturated).toBe(false); // 自己回復 + }); + + it('never decrements below zero', () => { + const h = new InferenceHealth(1); + h.markResolved(); + h.markResolved(); + expect(h.stuckCount).toBe(0); + }); }); describe('SaturationMonitor', () => { - it('signals exit only after threshold consecutive saturated observations', () => { - const m = new SaturationMonitor(3); - expect(m.observe(true)).toBe(false); - expect(m.observe(true)).toBe(false); - expect(m.consecutiveCount).toBe(2); - expect(m.observe(true)).toBe(true); // 3 回連続で exit 判定 - }); - - it('resets the consecutive count when an observation is not saturated', () => { - const m = new SaturationMonitor(2); - expect(m.observe(true)).toBe(false); - expect(m.observe(false)).toBe(false); // 一過性なのでリセット - expect(m.consecutiveCount).toBe(0); - expect(m.observe(true)).toBe(false); // 1 回目から数え直し - expect(m.observe(true)).toBe(true); - }); - - it('rejects a non-positive threshold', () => { - expect(() => new SaturationMonitor(0)).toThrow(RangeError); - expect(() => new SaturationMonitor(1.5)).toThrow(RangeError); - }); + it('signals exit only after threshold consecutive saturated observations', () => { + const m = new SaturationMonitor(3); + expect(m.observe(true)).toBe(false); + expect(m.observe(true)).toBe(false); + expect(m.consecutiveCount).toBe(2); + expect(m.observe(true)).toBe(true); // 3 回連続で exit 判定 + }); + + it('resets the consecutive count when an observation is not saturated', () => { + const m = new SaturationMonitor(2); + expect(m.observe(true)).toBe(false); + expect(m.observe(false)).toBe(false); // 一過性なのでリセット + expect(m.consecutiveCount).toBe(0); + expect(m.observe(true)).toBe(false); // 1 回目から数え直し + expect(m.observe(true)).toBe(true); + }); + + it('rejects a non-positive threshold', () => { + expect(() => new SaturationMonitor(0)).toThrow(RangeError); + expect(() => new SaturationMonitor(1.5)).toThrow(RangeError); + }); }); diff --git a/packages/core/test/helpers/integration-guard.ts b/packages/core/test/helpers/integration-guard.ts index d16604e..2d94d59 100644 --- a/packages/core/test/helpers/integration-guard.ts +++ b/packages/core/test/helpers/integration-guard.ts @@ -10,5 +10,5 @@ export const TEST_MODEL_DIR = rawModelDir.endsWith('/') ? rawModelDir : `${rawMo * モデルが無い環境では describe.skip させる。 */ export async function integrationEnabled(): Promise { - return existsSync(TEST_MODEL_DIR); + return existsSync(TEST_MODEL_DIR); } diff --git a/packages/core/test/helpers/png.ts b/packages/core/test/helpers/png.ts index f401fba..26e1648 100644 --- a/packages/core/test/helpers/png.ts +++ b/packages/core/test/helpers/png.ts @@ -4,64 +4,64 @@ import { deflateSync } from 'node:zlib'; // ONNX モデルが想定する「Misskey 側で 299x299 リサイズ+透過塗りつぶし済み」の入力を模す。 const crcTable: Uint32Array = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n += 1) { - let c = n; - for (let k = 0; k < 8; k += 1) { - c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - table[n] = c >>> 0; - } - return table; + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; })(); function crc32(buf: Uint8Array): number { - let c = 0xffffffff; - for (const byte of buf) { - c = (crcTable[(c ^ byte) & 0xff] ?? 0) ^ (c >>> 8); - } - return (c ^ 0xffffffff) >>> 0; + let c = 0xffffffff; + for (const byte of buf) { + c = (crcTable[(c ^ byte) & 0xff] ?? 0) ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; } function chunk(type: string, data: Uint8Array): Buffer { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length, 0); - const body = Buffer.concat([Buffer.from(type, 'ascii'), Buffer.from(data)]); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(body), 0); - return Buffer.concat([len, body, crc]); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, 'ascii'), Buffer.from(data)]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crc]); } /** 単色で塗りつぶした width x height の RGB PNG バッファを返す。 */ export function encodeRgbPng(width: number, height: number, rgb: readonly [number, number, number]): Buffer { - const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr.writeUInt8(8, 8); // bit depth - ihdr.writeUInt8(2, 9); // color type: 2 = truecolor (RGB) - ihdr.writeUInt8(0, 10); // compression - ihdr.writeUInt8(0, 11); // filter - ihdr.writeUInt8(0, 12); // interlace + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr.writeUInt8(8, 8); // bit depth + ihdr.writeUInt8(2, 9); // color type: 2 = truecolor (RGB) + ihdr.writeUInt8(0, 10); // compression + ihdr.writeUInt8(0, 11); // filter + ihdr.writeUInt8(0, 12); // interlace - const stride = 1 + width * 3; // 1 filter byte per scanline - const raw = Buffer.alloc(height * stride); - for (let y = 0; y < height; y += 1) { - const rowStart = y * stride; - raw[rowStart] = 0; // filter type: none - for (let x = 0; x < width; x += 1) { - const px = rowStart + 1 + x * 3; - raw[px] = rgb[0]; - raw[px + 1] = rgb[1]; - raw[px + 2] = rgb[2]; - } - } + const stride = 1 + width * 3; // 1 filter byte per scanline + const raw = Buffer.alloc(height * stride); + for (let y = 0; y < height; y += 1) { + const rowStart = y * stride; + raw[rowStart] = 0; // filter type: none + for (let x = 0; x < width; x += 1) { + const px = rowStart + 1 + x * 3; + raw[px] = rgb[0]; + raw[px + 1] = rgb[1]; + raw[px + 2] = rgb[2]; + } + } - return Buffer.concat([ - signature, - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]); + return Buffer.concat([ + signature, + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); } diff --git a/packages/core/test/semaphore.test.ts b/packages/core/test/semaphore.test.ts index ccd1849..fbf5868 100644 --- a/packages/core/test/semaphore.test.ts +++ b/packages/core/test/semaphore.test.ts @@ -4,105 +4,105 @@ import { Semaphore } from '../src/semaphore.js'; const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); describe('Semaphore', () => { - it('throws for a non-positive-integer concurrency', () => { - expect(() => new Semaphore(0)).toThrow(RangeError); - expect(() => new Semaphore(-1)).toThrow(RangeError); - expect(() => new Semaphore(1.5)).toThrow(RangeError); - }); + it('throws for a non-positive-integer concurrency', () => { + expect(() => new Semaphore(0)).toThrow(RangeError); + expect(() => new Semaphore(-1)).toThrow(RangeError); + expect(() => new Semaphore(1.5)).toThrow(RangeError); + }); - it('allows up to max concurrent holders and queues the rest', async () => { - const sem = new Semaphore(2); - const r1 = await sem.acquire(); - const r2 = await sem.acquire(); - expect(sem.activeCount).toBe(2); + it('allows up to max concurrent holders and queues the rest', async () => { + const sem = new Semaphore(2); + const r1 = await sem.acquire(); + const r2 = await sem.acquire(); + expect(sem.activeCount).toBe(2); - let third = false; - const p3 = sem.acquire().then((release) => { - third = true; - return release; - }); - await tick(); - expect(third).toBe(false); // still queued - expect(sem.pendingCount).toBe(1); + let third = false; + const p3 = sem.acquire().then((release) => { + third = true; + return release; + }); + await tick(); + expect(third).toBe(false); // still queued + expect(sem.pendingCount).toBe(1); - r1(); - const r3 = await p3; - expect(third).toBe(true); - expect(sem.activeCount).toBe(2); + r1(); + const r3 = await p3; + expect(third).toBe(true); + expect(sem.activeCount).toBe(2); - r2(); - r3(); - expect(sem.activeCount).toBe(0); - }); + r2(); + r3(); + expect(sem.activeCount).toBe(0); + }); - it('dispatches queued waiters in FIFO order', async () => { - const sem = new Semaphore(1); - const r1 = await sem.acquire(); - const order: number[] = []; - const p2 = sem.acquire().then((rel) => { - order.push(2); - return rel; - }); - const p3 = sem.acquire().then((rel) => { - order.push(3); - return rel; - }); - await tick(); - expect(order).toEqual([]); + it('dispatches queued waiters in FIFO order', async () => { + const sem = new Semaphore(1); + const r1 = await sem.acquire(); + const order: number[] = []; + const p2 = sem.acquire().then((rel) => { + order.push(2); + return rel; + }); + const p3 = sem.acquire().then((rel) => { + order.push(3); + return rel; + }); + await tick(); + expect(order).toEqual([]); - r1(); - const r2 = await p2; - r2(); - const r3 = await p3; - r3(); - expect(order).toEqual([2, 3]); - }); + r1(); + const r2 = await p2; + r2(); + const r3 = await p3; + r3(); + expect(order).toEqual([2, 3]); + }); - it('rejects acquire when the signal is already aborted', async () => { - const sem = new Semaphore(1); - const controller = new AbortController(); - controller.abort(); - await expect(sem.acquire(controller.signal)).rejects.toBeDefined(); - expect(sem.activeCount).toBe(0); - }); + it('rejects acquire when the signal is already aborted', async () => { + const sem = new Semaphore(1); + const controller = new AbortController(); + controller.abort(); + await expect(sem.acquire(controller.signal)).rejects.toBeDefined(); + expect(sem.activeCount).toBe(0); + }); - it('rejects a waiting acquire when aborted and removes it from the queue', async () => { - const sem = new Semaphore(1); - const r1 = await sem.acquire(); - const controller = new AbortController(); - const waiting = sem.acquire(controller.signal); - await tick(); - expect(sem.pendingCount).toBe(1); + it('rejects a waiting acquire when aborted and removes it from the queue', async () => { + const sem = new Semaphore(1); + const r1 = await sem.acquire(); + const controller = new AbortController(); + const waiting = sem.acquire(controller.signal); + await tick(); + expect(sem.pendingCount).toBe(1); - controller.abort(); - await expect(waiting).rejects.toBeDefined(); - expect(sem.pendingCount).toBe(0); + controller.abort(); + await expect(waiting).rejects.toBeDefined(); + expect(sem.pendingCount).toBe(0); - // The slot held by r1 is still ours; releasing it returns to zero. - r1(); - expect(sem.activeCount).toBe(0); - }); + // The slot held by r1 is still ours; releasing it returns to zero. + r1(); + expect(sem.activeCount).toBe(0); + }); - it('releases the slot when runExclusive throws', async () => { - const sem = new Semaphore(1); - await expect( - sem.runExclusive(() => { - throw new Error('boom'); - }), - ).rejects.toThrow('boom'); - expect(sem.activeCount).toBe(0); + it('releases the slot when runExclusive throws', async () => { + const sem = new Semaphore(1); + await expect( + sem.runExclusive(() => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + expect(sem.activeCount).toBe(0); - // Still usable afterwards. - const release = await sem.acquire(); - expect(sem.activeCount).toBe(1); - release(); - }); + // Still usable afterwards. + const release = await sem.acquire(); + expect(sem.activeCount).toBe(1); + release(); + }); - it('treats a double release as a no-op', async () => { - const sem = new Semaphore(1); - const release = await sem.acquire(); - release(); - release(); - expect(sem.activeCount).toBe(0); - }); + it('treats a double release as a no-op', async () => { + const sem = new Semaphore(1); + const release = await sem.acquire(); + release(); + release(); + expect(sem.activeCount).toBe(0); + }); }); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 2f6b166..2f67a7c 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,12 +1,12 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['./src/index.ts'], - outDir: 'dist', - format: ['esm'], - platform: 'node', - target: 'node22', - dts: true, - sourcemap: true, - clean: true, + entry: ['./src/index.ts'], + outDir: 'dist', + format: ['esm'], + platform: 'node', + target: 'node22', + dts: true, + sourcemap: true, + clean: true, }); diff --git a/scripts/healthcheck.mjs b/scripts/healthcheck.mjs index 6bd7352..358771b 100644 --- a/scripts/healthcheck.mjs +++ b/scripts/healthcheck.mjs @@ -11,14 +11,14 @@ const config = (await import(configPath)).default ?? {}; const connectHost = config.host && config.host !== '0.0.0.0' ? config.host : '127.0.0.1'; const base = { path: '/health', timeout: 3000 }; const options = config.socket - ? { ...base, socketPath: config.socket } - : { ...base, host: connectHost, port: config.port ?? 3009 }; + ? { ...base, socketPath: config.socket } + : { ...base, host: connectHost, port: config.port ?? 3009 }; const req = http.get(options, (res) => { - process.exit(res.statusCode === 200 ? 0 : 1); + process.exit(res.statusCode === 200 ? 0 : 1); }); req.on('error', () => process.exit(1)); req.on('timeout', () => { - req.destroy(); - process.exit(1); + req.destroy(); + process.exit(1); }); diff --git a/vitest.config.ts b/vitest.config.ts index 3bf4ff1..b4420b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,29 +8,29 @@ const coreSrc = fileURLToPath(new URL('./packages/core/src/index.ts', import.met const alias = { '@misskey-sensitive-detector/core': coreSrc }; export default defineConfig({ - test: { - projects: [ - { - resolve: { alias }, - test: { - name: 'unit', - include: ['packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], - exclude: ['**/*.integration.test.ts', '**/node_modules/**', '**/dist/**'], - }, - }, - { - resolve: { alias }, - test: { - name: 'integration', - include: ['packages/*/test/**/*.integration.test.ts', 'apps/*/test/**/*.integration.test.ts'], - exclude: ['**/node_modules/**', '**/dist/**'], - // onnxruntime-node のネイティブバイナリ安定動作のため、forks プールで直列実行する。 - pool: 'forks', - fileParallelism: false, - testTimeout: 120_000, - hookTimeout: 120_000, - }, - }, - ], - }, + test: { + projects: [ + { + resolve: { alias }, + test: { + name: 'unit', + include: ['packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], + exclude: ['**/*.integration.test.ts', '**/node_modules/**', '**/dist/**'], + }, + }, + { + resolve: { alias }, + test: { + name: 'integration', + include: ['packages/*/test/**/*.integration.test.ts', 'apps/*/test/**/*.integration.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + // onnxruntime-node のネイティブバイナリ安定動作のため、forks プールで直列実行する。 + pool: 'forks', + fileParallelism: false, + testTimeout: 120_000, + hookTimeout: 120_000, + }, + }, + ], + }, });