SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
// pnpm --filter @snowluma/common exec vitest bench --run bench/logger.bench.ts
import { bench, describe, vi } from 'vitest';
vi.stubEnv('SNOWLUMA_LOG_LEVEL', 'trace');
vi.stubEnv('SNOWLUMA_LOG_FILE', '0');
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'debug');
vi.stubEnv('SNOWLUMA_TRACE_BUFFER', '5000');
const { createLogger, getLogSnapshot, getRecentLogs, setLogLevel } = await import('../src/logger');
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const log = createLogger('Benchmark');
for (let i = 0; i < 5000; i += 1) {
log.trace('trace %d', i);
if (i % 5 === 0) {
log.info('normal %d', i);
}
}
stdout.mockRestore();
describe('6,000 retained records', () => {
bench('recent 30', () => {
getRecentLogs(30);
});
bench('recent 300', () => {
getRecentLogs(300);
});
bench('complete export', () => {
getLogSnapshot();
});
});
setLogLevel('info');
describe('file-only debug records with disk disabled', () => {
bench('format and route debug', () => {
log.debug('packet command=%s bytes=%d', 'Example.Service', 1024);
});
});
+123
View File
@@ -0,0 +1,123 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/common",
"version": "2.4.2",
"private": true,
"type": "module",
"description": "SnowLuma shared infrastructure — leaf-package utilities with zero business dependencies. Logger / hex codec / log file transport / log line summariser / packet sender wire-protocol types / runtime config loader. Consumed by @snowluma/protocol, @snowluma/core, and @snowluma/onebot. Per-file subpath exports keep import sites unambiguous and let the bundler tree-shake aggressively (importing `@snowluma/common/hex` doesn't pull in `node:fs` from the logger transport).",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./webui-target": {
"types": "./src/webui-target.ts",
"default": "./src/webui-target.ts"
},
"./update-source": {
"types": "./src/update-source.ts",
"default": "./src/update-source.ts"
},
"./semver": {
"types": "./src/semver.ts",
"default": "./src/semver.ts"
},
"./version": {
"types": "./src/version.ts",
"default": "./src/version.ts"
},
"./native-path": {
"types": "./src/native-path.ts",
"default": "./src/native-path.ts"
},
"./errors": {
"types": "./src/errors.ts",
"default": "./src/errors.ts"
},
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./logger": {
"types": "./src/logger.ts",
"default": "./src/logger.ts"
},
"./coerce": {
"types": "./src/coerce.ts",
"default": "./src/coerce.ts"
},
"./env": {
"types": "./src/env.ts",
"default": "./src/env.ts"
},
"./notification-templates": {
"types": "./src/notification-templates.ts",
"default": "./src/notification-templates.ts"
},
"./single-flight-cache": {
"types": "./src/single-flight-cache.ts",
"default": "./src/single-flight-cache.ts"
},
"./concurrency": {
"types": "./src/concurrency.ts",
"default": "./src/concurrency.ts"
},
"./hex": {
"types": "./src/hex.ts",
"default": "./src/hex.ts"
},
"./json": {
"types": "./src/json.ts",
"default": "./src/json.ts"
},
"./log-file-transport": {
"types": "./src/log-file-transport.ts",
"default": "./src/log-file-transport.ts"
},
"./log-sanitize": {
"types": "./src/log-sanitize.ts",
"default": "./src/log-sanitize.ts"
},
"./log-summary": {
"types": "./src/log-summary.ts",
"default": "./src/log-summary.ts"
},
"./packet-sender": {
"types": "./src/packet-sender.ts",
"default": "./src/packet-sender.ts"
},
"./protocol-types": {
"types": "./src/protocol-types.ts",
"default": "./src/protocol-types.ts"
},
"./runtime": {
"types": "./src/runtime.ts",
"default": "./src/runtime.ts"
},
"./uin": {
"types": "./src/uin.ts",
"default": "./src/uin.ts"
},
"./access-token": {
"types": "./src/access-token.ts",
"default": "./src/access-token.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/semver": "7.7.1",
"typescript": "catalog:",
"vitest": "4.1.12"
},
"dependencies": {
"@zxcvbn-ts/core": "^4.1.2",
"@zxcvbn-ts/language-common": "^4.1.3",
"@zxcvbn-ts/language-en": "^4.1.1",
"semver": "7.7.4"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
+68
View File
@@ -0,0 +1,68 @@
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
import { adjacencyGraphs, dictionary } from '@zxcvbn-ts/language-common';
import { translations } from '@zxcvbn-ts/language-en';
export const ACCESS_TOKEN_MIN_LENGTH = 16;
export const ACCESS_TOKEN_MIN_SCORE = 3;
export type AccessTokenAssessmentReason = 'empty' | 'too-short' | 'guessable' | 'acceptable';
export interface AccessTokenAssessment {
acceptable: boolean;
reason: AccessTokenAssessmentReason;
score: number;
}
const checker = new ZxcvbnFactory({
translations,
graphs: adjacencyGraphs,
dictionary,
});
function normalizeUserInputs(values: readonly (string | number)[]): Array<string | number> {
const unique = new Set<string | number>(['SnowLuma', 'OneBot']);
for (const value of values) {
if (typeof value === 'number') {
if (Number.isFinite(value)) {
unique.add(value);
}
continue;
}
const trimmed = value.trim();
if (trimmed) {
unique.add(trimmed.slice(0, 256));
}
}
return [...unique].slice(0, 24);
}
/**
* Evaluate a newly chosen OneBot access token against the shared product
* policy. Callers decide whether an empty token is permitted in their own
* trust context; an empty value is never considered strong here.
*/
export function assessAccessToken(
token: string,
userInputs: readonly (string | number)[] = [],
): AccessTokenAssessment {
if (!token) {
return { acceptable: false, reason: 'empty', score: 0 };
}
if (token.length < ACCESS_TOKEN_MIN_LENGTH) {
return { acceptable: false, reason: 'too-short', score: 0 };
}
const score = checker.check(token, normalizeUserInputs(userInputs)).score;
if (score < ACCESS_TOKEN_MIN_SCORE) {
return { acceptable: false, reason: 'guessable', score };
}
return { acceptable: true, reason: 'acceptable', score };
}
+34
View File
@@ -0,0 +1,34 @@
// Scalar coercion primitives for normalizing untrusted JSON config into
// typed values with fallbacks. Zero dependencies — these were duplicated
// verbatim across notifications/config and webui/ui-config; this is their
// single home. Input tolerance is deliberate (numeric strings coerce), but
// validity is strict (a wrong type yields the caller's fallback, never a
// silent 0/'' — except where the primitive's contract says otherwise).
/** True only for a plain, non-null, non-array object. */
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** The value when it is a real boolean, else the fallback. Strings/numbers
* are NOT coerced — `'true'` and `1` both yield the fallback. */
export function boolOr(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
/** A finite number (numeric strings coerced) clamped to `[min, max]`, else
* the fallback. Fractional precision is preserved — use `clampInt` to floor. */
export function clampNum(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
if (!Number.isFinite(n)) {
return fallback;
}
return Math.min(max, Math.max(min, n));
}
/** `clampNum` truncated toward zero — clamp first, then drop the fraction. */
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
return Math.trunc(clampNum(value, min, max, fallback));
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Map `items` through `fn` in sequential batches of at most `concurrency`
* items, awaiting each batch (`Promise.all`) before starting the next.
* Results come back in input order.
*
* Batched, not a rolling pool: every item in a batch must settle before the
* next batch starts. This caps peak parallelism — used to keep rate-limited
* fan-out (e.g. the per-uin OIDB profile fetches during forward-node
* enrichment) from bursting past a safe width and tripping Tencent
* risk-control. Pair it with a cap on `items` (slice) and per-item error
* handling inside `fn` for a fully bounded, best-effort enrichment — the
* shape that was previously hand-rolled at each such call site.
*/
export async function mapWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const limit = Math.max(1, Math.trunc(concurrency));
const results = new Array<R>(items.length);
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
const settled = await Promise.all(batch.map((item, j) => fn(item, i + j)));
for (let j = 0; j < settled.length; j++) {
results[i + j] = settled[j]!;
}
}
return results;
}
+34
View File
@@ -0,0 +1,34 @@
import { createLogger } from './logger';
const log = createLogger('Env');
/** Node's setTimeout delay is a signed 32-bit integer. */
const MAX_POSITIVE_INT = 2_147_483_647;
/** Read a positive integer environment variable.
*
* A blank value is absent and returns `fallback`. A present but unusable
* value is ignored, with a warning, and also returns `fallback`.
*/
export function positiveIntEnv(
name: string,
fallback: number,
options?: { max?: number; env?: NodeJS.ProcessEnv },
): number {
const max = options?.max ?? MAX_POSITIVE_INT;
const raw = (options?.env ?? process.env)[name];
if (typeof raw !== 'string' || !raw.trim()) {
return fallback;
}
const value = raw.trim();
const parsed = /^[1-9]\d*$/.test(value) ? Number(value) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed > max) {
log.warn('%s=%j is ignored; expected a positive integer up to %d', name, raw, max);
return fallback;
}
return parsed;
}
+3
View File
@@ -0,0 +1,3 @@
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+27
View File
@@ -0,0 +1,27 @@
export function toHex(data: Uint8Array | Buffer): string {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('hex');
}
export function toHexUpper(data: Uint8Array | Buffer): string {
return toHex(data).toUpperCase();
}
export function hexPreview(data: Uint8Array | Buffer, maxBytes = 64): string {
const limit = Math.max(0, Math.min(maxBytes, data.length));
const head = toHex(data.subarray(0, limit));
return data.length > limit ? `${head}...` : head;
}
export function fromHex(hex: string): Buffer {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const buf = Buffer.alloc(hex.length / 2);
for (let i = 0; i < buf.length; i++) {
buf[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return buf;
}
+6
View File
@@ -0,0 +1,6 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export interface JsonObject {
[key: string]: JsonValue;
}
export type JsonArray = JsonValue[];
File diff suppressed because it is too large. Load diff
+130
View File
@@ -0,0 +1,130 @@
const ESC = 0x1b;
const BEL = 0x07;
const C1_CSI = 0x9b;
const C1_ST = 0x9c;
const C1_OSC = 0x9d;
const C1_STRING_STARTERS = new Set([0x90, 0x98, 0x9e, 0x9f]);
function skipCsi(line: string, start: number): number {
for (let i = start; i < line.length; i += 1) {
const code = line.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
return i + 1;
}
}
return line.length;
}
function skipControlString(line: string, start: number, allowBell: boolean): number {
for (let i = start; i < line.length; i += 1) {
const code = line.charCodeAt(i);
if (allowBell && code === BEL) {
return i + 1;
}
if (code === C1_ST) {
return i + 1;
}
if (code === ESC && line.charCodeAt(i + 1) === 0x5c) {
return i + 2;
}
}
return line.length;
}
function skipEscSequence(line: string, start: number): number {
if (start >= line.length) {
return line.length;
}
const first = line.charCodeAt(start);
if (first === 0x5b) {
return skipCsi(line, start + 1);
}
if (first === 0x5d) {
return skipControlString(line, start + 1, true);
}
if (first === 0x50 || first === 0x58 || first === 0x5e || first === 0x5f) {
return skipControlString(line, start + 1, false);
}
let i = start;
while (i < line.length) {
const code = line.charCodeAt(i);
if (code < 0x20 || code > 0x2f) {
break;
}
i += 1;
}
const final = line.charCodeAt(i);
return final >= 0x30 && final <= 0x7e ? i + 1 : start;
}
/**
* Remove terminal presentation and control sequences from a log line.
*
* The parser handles both seven-bit ESC forms and eight-bit C1 forms,
* including CSI styling, OSC hyperlinks, and DCS/SOS/PM/APC control strings.
* TAB and LF remain available for formatted records such as stack traces.
* Unterminated terminal strings consume the remainder instead of leaking their
* invisible payload into persisted or downloaded logs.
*/
export function sanitizeLogLine(line: string): string {
const parts: string[] = [];
let start = 0;
for (let i = 0; i < line.length;) {
const code = line.charCodeAt(i);
let next: number;
if (code === ESC) {
next = skipEscSequence(line, i + 1);
} else if (code === C1_CSI) {
next = skipCsi(line, i + 1);
} else if (code === C1_OSC || C1_STRING_STARTERS.has(code)) {
next = skipControlString(line, i + 1, code === C1_OSC);
} else if (
(code < 0x20 && code !== 0x09 && code !== 0x0a) ||
(code >= 0x7f && code <= 0x9f)
) {
next = i + 1;
} else {
i += 1;
continue;
}
parts.push(line.slice(start, i));
start = next;
i = next;
}
if (start === 0) {
return line;
}
// Join contiguous spans once. Appending each character creates a retained
// V8 cons-string node per character in the in-memory log rings.
parts.push(line.slice(start));
const plain = parts.join('');
// A single visible span can remain a slice of the original input after join.
// Copy when that would retain more discarded text than visible text. Splitting
// into UTF-16 code units preserves unpaired surrogates and works in browsers.
if (plain.length > 0 && plain.length < line.length / 2) {
return plain.split('').join('');
}
return plain;
}
+383
View File
@@ -0,0 +1,383 @@
const MAX_FIELD = 40;
const MAX_TOTAL = 200;
const SENSITIVE_SEGMENTS = new Set([
'authorization',
'cookie',
'credential',
'credentials',
'password',
'passwd',
'secret',
'token',
'apikey',
'privatekey',
'sessionkey',
]);
function isSensitiveKey(key: string): boolean {
const segments = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[-_.\s]+/)
.filter(Boolean)
.map((segment) => segment.toLowerCase());
return segments.some((segment, index) => {
if (SENSITIVE_SEGMENTS.has(segment)) {
return true;
}
const next = segments[index + 1];
return (
next === 'key' && (segment === 'api' || segment === 'private' || segment === 'session')
);
});
}
function valueRepr(v: unknown, key?: string): string {
if (key !== undefined && isSensitiveKey(key)) {
return '"***"';
}
if (v === null) {
return 'null';
}
if (v === undefined) {
return 'undefined';
}
switch (typeof v) {
case 'string':
return v.length > MAX_FIELD ? `"${v.slice(0, MAX_FIELD)}..."` : `"${v}"`;
case 'number':
case 'boolean':
case 'bigint':
return String(v);
case 'object':
if (Array.isArray(v)) {
return `[len=${v.length}]`;
}
return '{...}';
default:
return typeof v;
}
}
/**
* Render a params object as a single line for logging. Skips deep
* traversal: nested objects collapse to `{...}`, arrays to `[len=N]`.
* Strings are quoted; long ones are truncated with an ellipsis.
*
* Output is capped at MAX_TOTAL chars; on overflow the tail is
* replaced with `...` so the next field doesn't get half-rendered.
*/
export function summarizeParams(params: unknown): string {
if (params === null || params === undefined) {
return '{}';
}
if (typeof params !== 'object') {
const s = String(params);
return s.length > MAX_TOTAL ? `${s.slice(0, MAX_TOTAL - 3)}...` : s;
}
if (Array.isArray(params)) {
return `[len=${params.length}]`;
}
const out: string[] = [];
let total = 0;
for (const [k, v] of Object.entries(params as Record<string, unknown>)) {
const entry = `${k}=${valueRepr(v, k)}`;
const separatorLength = out.length > 0 ? 1 : 0;
if (total + separatorLength + entry.length > MAX_TOTAL) {
if (out.length === 0) {
return `${entry.slice(0, MAX_TOTAL - 3)}...`;
}
const rendered = out.join(' ');
return rendered.length + 4 <= MAX_TOTAL
? `${rendered} ...`
: `${rendered.slice(0, MAX_TOTAL - 3)}...`;
}
out.push(entry);
total += separatorLength + entry.length;
}
return out.join(' ');
}
const ASSIGNMENT_START =
/(^|[^A-Za-z0-9_-])(["']?)([-_]*(?=[A-Za-z0-9_.-]*[A-Za-z])[A-Za-z0-9][A-Za-z0-9_.-]*)\2(\s*[:=]\s*)/gi;
const AUTHORIZATION_BOUNDARY_SEGMENTS = new Set([
'authorization',
'cookie',
'password',
'passwd',
'secret',
'token',
'apikey',
'privatekey',
'sessionkey',
]);
function keySegments(key: string): string[] {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[-_.\s]+/)
.filter(Boolean)
.map((segment) => segment.toLowerCase());
}
function isKeyKind(key: string, kind: 'authorization' | 'cookie'): boolean {
return keySegments(key).includes(kind);
}
function isAuthorizationBoundaryKey(key: string): boolean {
const segments = keySegments(key);
return segments.some((segment, index) => {
if (AUTHORIZATION_BOUNDARY_SEGMENTS.has(segment)) {
return true;
}
const next = segments[index + 1];
return (
next === 'key' && (segment === 'api' || segment === 'private' || segment === 'session')
);
});
}
function quotedValueEnd(message: string, start: number): number | undefined {
const quote = message[start];
if (quote !== '"' && quote !== "'") {
return undefined;
}
for (let index = start + 1; index < message.length; index += 1) {
if (message[index] === '\\') {
index += 1;
} else if (message[index] === quote) {
return index + 1;
}
}
return message.length;
}
function cookieValueEnd(message: string, start: number): number {
const initialQuotedEnd = quotedValueEnd(message, start);
let end: number;
if (initialQuotedEnd !== undefined) {
end = initialQuotedEnd;
} else {
end = start;
while (end < message.length && !/[;\s,}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
}
while (end < message.length) {
let semicolon: number = end;
while (message[semicolon] === ' ' || message[semicolon] === '\t') {
semicolon += 1;
}
if (message[semicolon] !== ';') {
break;
}
let nameStart = semicolon + 1;
while (message[nameStart] === ' ' || message[nameStart] === '\t') {
nameStart += 1;
}
let nameEnd = nameStart;
while (/[A-Za-z0-9_-]/.test(message[nameEnd] ?? '')) {
nameEnd += 1;
}
if (nameEnd === nameStart) {
break;
}
end = nameEnd;
if (message[end] !== '=') {
continue;
}
const attribute = message.slice(nameStart, nameEnd).toLowerCase();
const valueStart = end + 1;
const quotedEnd = quotedValueEnd(message, valueStart);
if (quotedEnd !== undefined) {
end = quotedEnd;
continue;
}
if (attribute !== 'expires') {
end = valueStart;
while (end < message.length && !/[;\s,}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
continue;
}
end = valueStart;
while (end < message.length && !/[;}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
const assignments = new RegExp(ASSIGNMENT_START.source, 'gi');
assignments.lastIndex = valueStart;
const nextField = assignments.exec(message);
if (nextField && nextField.index < end) {
end = nextField.index;
}
}
return end;
}
function genericValueEnd(message: string, start: number): number {
const quotedEnd = quotedValueEnd(message, start);
if (quotedEnd !== undefined) {
return quotedEnd;
}
let end = start;
while (end < message.length && !/[\s,}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
return end;
}
function authorizationValueEnd(message: string, start: number, query: boolean): number {
const quotedEnd = quotedValueEnd(message, start);
if (quotedEnd !== undefined) {
return quotedEnd;
}
if (query) {
const queryEnd = message.indexOf('&', start);
return queryEnd >= 0 ? queryEnd : message.length;
}
let end = message.length;
const structural = message.slice(start).search(/[\r\n}\]]/);
if (structural >= 0) {
end = start + structural;
}
const assignments = new RegExp(ASSIGNMENT_START.source, 'gi');
const remaining = message.slice(start, end);
for (let match = assignments.exec(remaining); match; match = assignments.exec(remaining)) {
if (isAuthorizationBoundaryKey(match[3]!)) {
end = start + match.index;
break;
}
}
return end;
}
/** Redact explicit authentication assignments in ordinary formatted logs. */
export function redactLogMessage(message: string): string {
let out = '';
let cursor = 0;
ASSIGNMENT_START.lastIndex = 0;
for (
let match = ASSIGNMENT_START.exec(message);
match;
match = ASSIGNMENT_START.exec(message)
) {
const key = match[3]!;
if (!isSensitiveKey(key)) {
ASSIGNMENT_START.lastIndex = Math.max(match.index + 1, ASSIGNMENT_START.lastIndex - 1);
continue;
}
const valueStart = ASSIGNMENT_START.lastIndex;
const valueEnd = isKeyKind(key, 'authorization')
? authorizationValueEnd(message, valueStart, match[1] === '?' || match[1] === '&')
: isKeyKind(key, 'cookie')
? cookieValueEnd(message, valueStart)
: genericValueEnd(message, valueStart);
out += message.slice(cursor, valueStart) + '***';
cursor = valueEnd;
ASSIGNMENT_START.lastIndex = valueEnd;
}
return out + message.slice(cursor);
}
/**
* Lossless nested renderer for explicit TRACE diagnostics. TRACE is an
* operator-enabled, memory-only mode and intentionally leaves values
* unredacted so a reproduction contains the complete business input.
*/
export function renderParamsVerbose(params: unknown): string {
const seen = new WeakSet<object>();
const walk = (value: unknown): string => {
if (value === null) {
return 'null';
}
if (value === undefined) {
return 'undefined';
}
switch (typeof value) {
case 'string':
return JSON.stringify(value);
case 'number':
case 'boolean':
case 'bigint':
return String(value);
case 'object': {
if (seen.has(value as object)) {
return '"[circular]"';
}
seen.add(value as object);
const out = Array.isArray(value)
? `[${value.map((item) => walk(item)).join(',')}]`
: `{${Object.entries(value as Record<string, unknown>)
.map(([key, item]) => `${key}:${walk(item)}`)
.join(',')}}`;
seen.delete(value as object);
return out;
}
default:
return typeof value;
}
};
return walk(params);
}
+453
View File
@@ -0,0 +1,453 @@
import { format } from 'util';
import { getFileTransport } from './log-file-transport';
import { redactLogMessage } from './log-summary';
import { sanitizeLogLine } from './log-sanitize';
import {
currentRequestId,
nextRequestId,
runWithoutRequestContext,
runWithRequestId,
} from './request-context';
type LogLevel = 'trace' | 'debug' | 'info' | 'success' | 'warn' | 'error';
type FileLogLevel = Exclude<LogLevel, 'trace'>;
export interface LogEntry {
id: number;
time: string;
level: LogLevel;
scope: string;
/** QQ uin, when the source logger was derived via `.child({ uin })`. */
uin?: number;
/** Correlation id, when emitted inside a `runWithRequestId(...)` scope. */
req?: number;
message: string;
line: string;
}
interface LogOptions {
scope: string;
uin?: number;
/** Free-form meta carried across `.child(...)` calls. Currently only
* `uin` is rendered, but the bag is preserved for future fields
* (requestId / traceId / etc.). */
meta?: Record<string, unknown>;
}
const UIN_SLOT_WIDTH = 12;
const LEVEL_WEIGHT: Record<LogLevel, number> = {
trace: 5,
debug: 10,
info: 20,
success: 25,
warn: 30,
error: 40,
};
const LEVEL_LABEL: Record<LogLevel, string> = {
trace: 'TRACE',
debug: 'DEBUG',
info: 'INFO',
success: 'OK',
warn: 'WARN',
error: 'ERROR',
};
const COLOR_CODE: Record<LogLevel, number> = {
trace: 90,
debug: 90,
info: 36,
success: 32,
warn: 33,
error: 31,
};
const COLOR_SCOPE = 35;
const COLOR_DIM = 2;
const COLOR_RESET = '\x1b[0m';
const MAX_LOG_ENTRIES = 1000;
/** Trace ring cap — env-tunable since trace is the high-volume stream. */
function resolveTraceBufferMax(): number {
const raw = Number.parseInt(process.env.SNOWLUMA_TRACE_BUFFER ?? '', 10);
return Number.isFinite(raw) && raw >= 100 ? raw : 5000;
}
const TRACE_BUFFER_MAX = resolveTraceBufferMax();
/**
* Fixed-capacity circular buffer. O(1) push + eviction (no array `.shift()`),
* so the high-throughput trace stream never pays an O(n) shift per overflow.
*/
class RingBuffer<T> {
private readonly buf: (T | undefined)[];
private start = 0;
private count = 0;
constructor(private readonly cap: number) {
this.buf = new Array<T | undefined>(cap);
}
push(item: T): void {
const end = (this.start + this.count) % this.cap;
this.buf[end] = item;
if (this.count < this.cap) {
this.count += 1;
} else {
this.start = (this.start + 1) % this.cap;
} // full → overwrite oldest
}
/** Read from newest to oldest without copying the retained buffer. */
newest(offset: number): T | undefined {
if (offset >= this.count) {
return undefined;
}
return this.buf[(this.start + this.count - 1 - offset) % this.cap];
}
get size(): number {
return this.count;
}
}
const logRing = new RingBuffer<LogEntry>(MAX_LOG_ENTRIES);
const traceRing = new RingBuffer<LogEntry>(TRACE_BUFFER_MAX);
const logSubscribers = new Set<(entry: LogEntry) => void>();
let nextLogId = 1;
function resolveMinLevel(): LogLevel {
const raw = (process.env.SNOWLUMA_LOG_LEVEL ?? 'info').toLowerCase();
if (
raw === 'trace' ||
raw === 'debug' ||
raw === 'info' ||
raw === 'success' ||
raw === 'warn' ||
raw === 'error'
) {
return raw;
}
return 'info';
}
function resolveFileMinLevel(): FileLogLevel {
const raw = process.env.SNOWLUMA_LOG_FILE_LEVEL;
if (raw === undefined || raw.trim() === '') {
return 'debug';
}
const normalized = raw.trim().toLowerCase();
if (
normalized === 'debug' ||
normalized === 'info' ||
normalized === 'success' ||
normalized === 'warn' ||
normalized === 'error'
) {
return normalized;
}
throw new TypeError(
'SNOWLUMA_LOG_FILE_LEVEL must be one of: debug, info, success, warn, error',
);
}
// Mutable — seeded from SNOWLUMA_LOG_LEVEL at module load, but
// setLogLevel() lets WebUI / SDK callers flip it without a restart.
// Only governs console + ring buffer + subscribers;
// the file transport level is seeded independently from
// SNOWLUMA_LOG_FILE_LEVEL (default debug)
// and is not affected by setLogLevel().
let currentLevel: LogLevel = resolveMinLevel();
const currentFileLevel: FileLogLevel = resolveFileMinLevel();
function shouldLog(level: LogLevel): boolean {
return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[currentLevel];
}
function shouldLogToFile(level: LogLevel): boolean {
return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[currentFileLevel];
}
export const LOG_LEVELS: readonly LogLevel[] = [
'trace',
'debug',
'info',
'success',
'warn',
'error',
];
export function getLogLevel(): LogLevel {
return currentLevel;
}
// Change the console / subscriber level at runtime.
// Invalid input is a no-op (returns false).
// Does not affect the file transport log level.
export function setLogLevel(level: string): boolean {
const lower = String(level).toLowerCase();
if (!LOG_LEVELS.includes(lower as LogLevel)) {
return false;
}
currentLevel = lower as LogLevel;
return true;
}
function useColor(): boolean {
if (process.env.NO_COLOR === '1') {
return false;
}
return Boolean(process.stdout.isTTY);
}
function ansi(code: number, text: string): string {
return `\x1b[${code}m${text}${COLOR_RESET}`;
}
function currentTime(): string {
const d = new Date();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function render(level: LogLevel, options: LogOptions, message: string, reqId?: number): string {
const ts = currentTime();
const label = LEVEL_LABEL[level].padEnd(5, ' ');
const uinTag = options.uin !== undefined ? `[${options.uin}]` : '';
const uinSlot = uinTag.padEnd(UIN_SLOT_WIDTH);
const reqTag = reqId !== undefined ? `[req#${reqId}]` : '';
if (!useColor()) {
return `${ts} ${label} ${uinSlot} [${options.scope}] ${reqTag ? `${reqTag} ` : ''}${message}`;
}
const cTs = ansi(COLOR_DIM, ts);
const cLabel = ansi(COLOR_CODE[level], label);
// Pad first, then color only the visible tag so escape codes don't eat
// into the alignment width.
const cUin = uinTag
? ansi(COLOR_DIM, uinTag) + ' '.repeat(Math.max(0, UIN_SLOT_WIDTH - uinTag.length))
: ' '.repeat(UIN_SLOT_WIDTH);
const cScope = ansi(COLOR_SCOPE, `[${options.scope}]`);
const cReq = reqTag ? `${ansi(COLOR_DIM, reqTag)} ` : '';
return `${cTs} ${cLabel} ${cUin} ${cScope} ${cReq}${message}`;
}
function emit(level: LogLevel, options: LogOptions, args: unknown[]): void {
// Console / subscriber and file filters are independent. If no destination
// accepts the record, stop before formatting or building a LogEntry.
const passesConsole = shouldLog(level);
const passesFile = level !== 'trace' && shouldLogToFile(level);
if (!passesConsole && !passesFile) {
return;
}
// `trace` is the high-volume full-chain diagnostic stream, intentionally
// excluded from disk to avoid I/O saturation. When console is not dialed
// to trace, the early return below skips format/render/disk entirely.
// Set SNOWLUMA_LOG_LEVEL=trace to inspect trace output in console/memory;
// trace never reaches disk regardless.
if (level === 'trace' && !passesConsole) {
return;
}
// Lazy trace form: `log.trace(() => ['…', deepRender(x)])`. The producer
// (and its expensive deep render) only runs now that trace is confirmed live.
let realArgs = args;
if (level === 'trace' && args.length === 1 && typeof args[0] === 'function') {
realArgs = (args[0] as () => unknown[])();
}
const reqId = currentRequestId();
const formattedMessage = format(...realArgs);
const message = level === 'trace' ? formattedMessage : redactLogMessage(formattedMessage);
const line = render(level, options, message, reqId);
const id = nextLogId++;
if (passesConsole) {
// File-only records are sanitized by the transport and need no memory
// entry, ISO timestamp, or second sanitized copy of the rendered line.
const entry: LogEntry = {
id,
time: new Date().toISOString(),
level,
scope: options.scope,
...(options.uin !== undefined ? { uin: options.uin } : {}),
...(reqId !== undefined ? { req: reqId } : {}),
message,
line: sanitizeLogLine(line),
};
// Trace gets its own ring so its volume never evicts the normal log buffer.
(level === 'trace' ? traceRing : logRing).push(entry);
for (const subscriber of logSubscribers) {
subscriber(entry);
}
const stream = level === 'warn' || level === 'error' ? process.stderr : process.stdout;
// Strip ASCII control characters before writing to terminal to prevent
// BEL (0x07) in user-provided strings (e.g. group member names) from
// triggering Windows system beep sounds. Exempt TAB (0x09), LF (0x0A)
// and ESC (0x1B) so multi-line records (stack traces) and ANSI color
// sequences emitted by render() pass through intact.
// eslint-disable-next-line no-control-regex
const consoleLine = line.replace(/[\x00-\x08\x0B-\x1A\x1C-\x1F\x7F]/g, '') + '\n';
try {
stream.write(consoleLine);
} catch {
// A full Docker json-file log volume can make stdout/stderr throw.
// Logging must not take down message handling or the WebUI.
}
}
// File transport sees levels >= currentFileLevel (SNOWLUMA_LOG_FILE_LEVEL,
// default debug). `trace` is memory / WebUI only (omitted here to avoid huge
// on-disk volume). ANSI stripping happens inside the transport. UIN routes
// the line to its per-account sub-file in addition to the shared one.
if (passesFile) {
getFileTransport().write(line, options.uin);
}
}
/**
* Flush and close the underlying log file. Call from shutdown hooks
* (SIGINT / SIGTERM / uncaughtException) so the WriteStream's internal
* buffer makes it to disk. Returns a promise that resolves once the OS
* has finalized the write.
*/
export function closeLogger(): Promise<void> {
return getFileTransport().close();
}
function mergeLogRings(limit = logRing.size + traceRing.size): LogEntry[] {
const take = Math.min(limit, logRing.size + traceRing.size);
const entries = new Array<LogEntry>(take);
let normalOffset = 0;
let traceOffset = 0;
// Both rings already follow increasing IDs. Merge backwards so a small
// WebUI request visits only the requested tail, even with a large TRACE ring.
for (let i = take - 1; i >= 0; i -= 1) {
const normal = logRing.newest(normalOffset);
const trace = traceRing.newest(traceOffset);
if (normal && (!trace || normal.id > trace.id)) {
entries[i] = normal;
normalOffset += 1;
} else {
entries[i] = trace!;
traceOffset += 1;
}
}
return entries;
}
export function getLogSnapshot(): LogEntry[] {
return mergeLogRings();
}
export function getRecentLogs(limit = 300): LogEntry[] {
const n = Math.max(1, Math.trunc(limit));
// Array.slice(-NaN) previously returned the full snapshot.
return mergeLogRings(Number.isNaN(n) ? undefined : n);
}
export function subscribeLogs(callback: (entry: LogEntry) => void): () => void {
logSubscribers.add(callback);
return () => {
logSubscribers.delete(callback);
};
}
export interface Logger {
/**
* High-volume, memory-only diagnostic level (never written to disk). Pass a
* lazy producer — `trace(() => ['msg %s', expensiveRender(x)])` — so the
* arguments (and any costly rendering) are built only when trace is active.
* A plain `trace('msg', x)` works too but evaluates its args eagerly.
*/
trace: (...args: unknown[]) => void;
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
success: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
/**
* Derive a new logger that inherits this one's scope and carries
* additional metadata. Currently `uin` is the only key with rendering
* support (shown in the `[UIN]` slot and routed to a per-UIN file);
* other keys are preserved on the options bag for forward compat.
*/
child: (meta: { uin?: number; [k: string]: unknown }) => Logger;
}
function makeLogger(opts: LogOptions): Logger {
return {
trace: (...args: unknown[]) => emit('trace', opts, args),
debug: (...args: unknown[]) => emit('debug', opts, args),
info: (...args: unknown[]) => emit('info', opts, args),
success: (...args: unknown[]) => emit('success', opts, args),
warn: (...args: unknown[]) => emit('warn', opts, args),
error: (...args: unknown[]) => emit('error', opts, args),
child: (meta) => {
const nextUin = typeof meta.uin === 'number' ? meta.uin : opts.uin;
return makeLogger({
scope: opts.scope,
uin: nextUin,
meta: { ...(opts.meta ?? {}), ...meta },
});
},
};
}
export function createLogger(scope: string): Logger {
return makeLogger({ scope });
}
export function renderTraceBytes(body: Uint8Array): string {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('hex');
}
export function runWithTraceRequest<T>(fn: () => T): T {
if (currentRequestId() !== undefined || currentLevel !== 'trace') {
return fn();
}
return runWithRequestId(nextRequestId(), fn);
}
export function logInitialWebuiCredentials(password: string): void {
// This is deliberately not a LogEntry: application logs can be retained on
// disk, exported through WebUI, and delivered to subscribers. First-run
// credentials must only cross the process terminal boundary.
const line = render(
'info',
{ scope: 'WebUI' },
format('initial credentials: user=admin password=%s', password),
);
// Keep the same terminal control-character policy as regular console logs.
// eslint-disable-next-line no-control-regex
process.stdout.write(line.replace(/[\x00-\x08\x0B-\x1A\x1C-\x1F\x7F]/g, '') + '\n');
}
// Request-correlation primitives remain available for callers that already own
// an explicit request id. New semantic entrypoints should prefer
// runWithTraceRequest() so disabled TRACE does not allocate one.
export { nextRequestId, runWithoutRequestContext, runWithRequestId, currentRequestId };
+21
View File
@@ -0,0 +1,21 @@
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
export function resolveRuntimeNativePath(fileName: string): string | null {
// Bundled releases and workspace sources each own their native inputs.
for (const directory of [
path.join(moduleDir, 'native'),
path.resolve(moduleDir, '..', '..', 'runtime', 'native'),
]) {
const candidate = path.join(directory, fileName);
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
@@ -0,0 +1,14 @@
export const DEFAULT_WEBHOOK_BODY = `{
"title": "账号状态通知:{event}",
"desp": "您的账号状态发生了改变。\\n\\n**昵称**:{nickname}\\n**QQ号**:{uin}\\n**当前状态**:{event}\\n**进程状态**:{status}\\n**时间**:{time}"
}`;
export const DEFAULT_EMAIL_SUBJECT = '账号{event}:{nickname} ({uin})';
export const DEFAULT_EMAIL_BODY = `账号状态发生了改变。
昵称:{nickname}
QQ号:{uin}
当前状态:{event}
进程状态:{status}
时间:{time}`;
+11
View File
@@ -0,0 +1,11 @@
export interface SendPacketResult {
success: boolean;
gotResponse: boolean;
errorCode: number;
errorMessage: string;
responseData: Buffer | null;
}
export interface PacketSender {
sendPacket(serviceCmd: string, body: Buffer, timeoutMs?: number): Promise<SendPacketResult>;
}
+14
View File
@@ -0,0 +1,14 @@
export const CLIENT_MESSAGE_RECEIPT_CMD = 'ClientMessageReceipt';
export interface PacketInfo {
pid: number;
uin: string;
packetType?: number;
serviceCmd: string;
seqId: number;
retCode: number;
fromClient: boolean;
source?: 'client-message';
body: Uint8Array;
}
export type PacketSink = (packet: PacketInfo) => void;
+44
View File
@@ -0,0 +1,44 @@
import { AsyncLocalStorage } from 'async_hooks';
interface RequestStore {
id: number;
}
const storage = new AsyncLocalStorage<RequestStore | undefined>();
let counter = 0;
/**
* Allocate the next per-process request id (monotonic). Wraps via uint32 so
* it never overflows to a non-integer; `0` is skipped so "no id" stays
* unambiguous.
*/
export function nextRequestId(): number {
counter = (counter + 1) >>> 0;
if (counter === 0) {
counter = 1;
}
return counter;
}
/**
* Run `fn` with `id` bound as the ambient request id for the entire async
* chain it spawns. Any logger call anywhere in that chain — across packages,
* across awaits — picks it up via {@link currentRequestId} with no signature
* threading. Used by the OneBot action handler to correlate a request's whole
* journey (entry → outbound packets → exit) under one `[req#N]` tag.
*/
export function runWithRequestId<T>(id: number, fn: () => T): T {
return storage.run({ id }, fn);
}
/** Run `fn` without inheriting an ambient request id. */
export function runWithoutRequestContext<T>(fn: () => T): T {
return storage.run(undefined, fn);
}
/** The request id bound to the current async context, or undefined outside one. */
export function currentRequestId(): number | undefined {
return storage.getStore()?.id;
}
+407
View File
@@ -0,0 +1,407 @@
import fs from 'fs';
import path from 'path';
import { isObject } from './coerce';
import { normalizeUpdateSourceConfig, type UpdateSourceConfig } from './update-source';
export interface RuntimeConfig {
updateSource?: UpdateSourceConfig;
webuiPort?: number;
/** When true, every newly-discovered QQ process gets auto-injected by
* the HookManager. Also overridable at runtime via SNOWLUMA_HOOK_AUTOLOAD.
* Defaults to false; the Docker image flips it on in supervisord.conf. */
hookAutoLoad?: boolean;
/** WebUI listener bind address. '127.0.0.1' = localhost-only (default),
* '0.0.0.0' = all IPv4 interfaces. Listener-level: change needs a restart. */
webuiHost?: string;
/** WebUI TLS. Cert/key live in config/cert.pem + config/key.pem; this
* only flips serving on/off. Listener-level: change needs a restart. */
webuiTls?: { enabled?: boolean };
/** Raw trust-proxy directive (same domain as SNOWLUMA_WEBUI_TRUST_PROXY),
* consumed by the WebUI's client-ip resolver. '' = trust nobody. */
trustProxy?: string;
/** Aggregate cap for every managed file below the log root. */
logMaxTotalMb?: number;
/** Calendar-day retention. Zero disables date-based deletion. */
logRetainDays?: number;
/** Duplicate UIN-scoped lines into logs/<uin>/ in addition to the shared log. */
logPerUin?: boolean;
}
const CONFIG_DIR = 'config';
const RUNTIME_CONFIG_PATH = path.join(CONFIG_DIR, 'runtime.json');
const DEFAULT_WEBUI_PORT = 5099;
const DEFAULT_WEBUI_HOST = '127.0.0.1';
export const DEFAULT_LOG_MAX_TOTAL_MB = 1024;
export const DEFAULT_LOG_RETAIN_DAYS = 7;
export const DEFAULT_LOG_PER_UIN = false;
export const MAX_LOG_TOTAL_MB = Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024));
export const MAX_LOG_RETAIN_DAYS = Math.floor(Number.MAX_SAFE_INTEGER / (24 * 60 * 60 * 1000));
/**
* Pure on-disk-object → typed config normalization (defaults + validation,
* no fs / no env). Exported for testing; `loadRuntimeConfig` wraps it.
*/
export function normalizeRuntimeConfig(parsed: unknown): RuntimeConfig {
const obj = isObject(parsed) ? parsed : {};
return {
...(obj.updateSource === undefined
? {}
: { updateSource: normalizeUpdateSourceConfig(obj.updateSource) }),
webuiPort: normalizePort(obj.webuiPort ?? DEFAULT_WEBUI_PORT, DEFAULT_WEBUI_PORT),
hookAutoLoad: normalizeBool(obj.hookAutoLoad, false),
webuiHost: normalizeHost(obj.webuiHost),
webuiTls: {
enabled: isObject(obj.webuiTls) ? normalizeBool(obj.webuiTls.enabled, false) : false,
},
trustProxy: typeof obj.trustProxy === 'string' ? obj.trustProxy : '',
logMaxTotalMb: normalizeRequiredInteger(
obj.logMaxTotalMb,
DEFAULT_LOG_MAX_TOTAL_MB,
1,
MAX_LOG_TOTAL_MB,
'logMaxTotalMb',
),
logRetainDays: normalizeRequiredInteger(
obj.logRetainDays,
DEFAULT_LOG_RETAIN_DAYS,
0,
MAX_LOG_RETAIN_DAYS,
'logRetainDays',
),
logPerUin: normalizeRequiredBool(obj.logPerUin, DEFAULT_LOG_PER_UIN, 'logPerUin'),
};
}
/**
* Pure SNOWLUMA_* env → override patch (no fs). Env wins over runtime.json
* (a trusted launcher like SnowLumaDesktop pins these per-launch without
* rewriting the file). Absent vars produce no key.
*/
export function resolveRuntimeEnvOverrides(env: NodeJS.ProcessEnv): Partial<RuntimeConfig> {
const out: Partial<RuntimeConfig> = {};
const port = parsePortString(env.SNOWLUMA_WEBUI_PORT);
if (port !== undefined) {
out.webuiPort = port;
}
const host = env.SNOWLUMA_WEBUI_HOST;
if (typeof host === 'string' && host.trim()) {
out.webuiHost = host.trim();
}
// Present-but-"0"/"off" is a deliberate override (trust nobody), distinct
// from "absent" — so gate on key presence, not truthiness.
const tp = env.SNOWLUMA_WEBUI_TRUST_PROXY;
if (typeof tp === 'string') {
out.trustProxy = tp;
}
const logMaxTotalMb = parseRequiredIntegerEnv(
env.SNOWLUMA_LOG_MAX_TOTAL_MB,
1,
MAX_LOG_TOTAL_MB,
'SNOWLUMA_LOG_MAX_TOTAL_MB',
);
if (logMaxTotalMb !== undefined) {
out.logMaxTotalMb = logMaxTotalMb;
}
const logRetainDays = parseRequiredIntegerEnv(
env.SNOWLUMA_LOG_RETAIN_DAYS,
0,
MAX_LOG_RETAIN_DAYS,
'SNOWLUMA_LOG_RETAIN_DAYS',
);
if (logRetainDays !== undefined) {
out.logRetainDays = logRetainDays;
}
const logPerUin = parseRequiredBoolEnv(env.SNOWLUMA_LOG_PER_UIN, 'SNOWLUMA_LOG_PER_UIN');
if (logPerUin !== undefined) {
out.logPerUin = logPerUin;
}
const webuiTls = parseRequiredBoolEnv(env.SNOWLUMA_WEBUI_TLS, 'SNOWLUMA_WEBUI_TLS');
if (webuiTls !== undefined) {
out.webuiTls = { enabled: webuiTls };
}
return out;
}
export function loadRuntimeConfig(): RuntimeConfig {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const parsed = tryLoadRuntimeConfig();
const normalized = normalizeRuntimeConfig(parsed ?? {});
// Persist when the file is absent or when normalization changed/backfilled
// anything (e.g. an older runtime.json lacking webuiHost/webuiTls).
if (parsed === null || !sameRuntimeConfig(parsed, normalized)) {
saveRuntimeConfig(normalized);
}
// Env overrides apply in-memory only — never written back to disk.
return { ...normalized, ...resolveRuntimeEnvOverrides(process.env) };
}
/**
* Read the persisted config (normalized, no env overrides, no write). For the
* settings panel's GET — shows what's actually saved/editable on disk.
*/
export function readRuntimeConfig(): RuntimeConfig {
return normalizeRuntimeConfig(tryLoadRuntimeConfig() ?? {});
}
/**
* Persist a partial update. Merges onto the ON-DISK config (not the env-merged
* runtime view) so an env override (e.g. SNOWLUMA_WEBUI_PORT) is never baked
* into runtime.json. Returns the new persisted config (without env overrides).
*/
export function updateRuntimeConfig(patch: Partial<RuntimeConfig>): RuntimeConfig {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const onDisk = normalizeRuntimeConfig(tryLoadRuntimeConfig() ?? {});
const next = normalizeRuntimeConfig({ ...onDisk, ...patch });
saveRuntimeConfig(next);
return next;
}
function tryLoadRuntimeConfig(): Record<string, unknown> | null {
if (!fs.existsSync(RUNTIME_CONFIG_PATH)) {
return null;
}
try {
const parsed = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_PATH, 'utf8')) as unknown;
return isObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function saveRuntimeConfig(config: RuntimeConfig): void {
const temporaryPath = `${RUNTIME_CONFIG_PATH}.tmp-${String(process.pid)}`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(config, null, 2), 'utf8');
fs.renameSync(temporaryPath, RUNTIME_CONFIG_PATH);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch (cleanupError) {
if (!isErrnoCode(cleanupError, 'ENOENT')) {
throw new AggregateError(
[error, cleanupError],
'failed to persist runtime config and remove its temporary file',
);
}
}
throw error;
}
}
/** True when the raw on-disk object already matches the normalized config
* on every known field (so we can skip a needless rewrite). */
function sameRuntimeConfig(parsed: Record<string, unknown>, n: RuntimeConfig): boolean {
const parsedTls = isObject(parsed.webuiTls) ? parsed.webuiTls.enabled : undefined;
return (
parsed.webuiPort === n.webuiPort &&
parsed.hookAutoLoad === n.hookAutoLoad &&
parsed.webuiHost === n.webuiHost &&
parsedTls === n.webuiTls?.enabled &&
parsed.trustProxy === n.trustProxy &&
parsed.logMaxTotalMb === n.logMaxTotalMb &&
parsed.logRetainDays === n.logRetainDays &&
parsed.logPerUin === n.logPerUin &&
JSON.stringify(parsed.updateSource) === JSON.stringify(n.updateSource)
);
}
function parsePortString(raw: unknown): number | undefined {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
const n = Number(raw.trim());
if (!Number.isFinite(n)) {
return undefined;
}
const port = Math.trunc(n);
if (port <= 0 || port > 65535) {
return undefined;
}
return port;
}
function normalizeHost(value: unknown): string {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
return DEFAULT_WEBUI_HOST;
}
function normalizePort(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
const n = Math.trunc(value);
if (n > 0 && n <= 65535) {
return n;
}
return fallback;
}
if (typeof value === 'string' && value.trim()) {
const n = Number(value);
if (Number.isFinite(n)) {
const port = Math.trunc(n);
if (port > 0 && port <= 65535) {
return port;
}
}
}
return fallback;
}
function normalizeRequiredInteger(
value: unknown,
fallback: number,
min: number,
max: number,
field: string,
): number {
if (value === undefined) {
return fallback;
}
const n = typeof value === 'string' && value.trim() ? Number(value.trim()) : value;
if (typeof n === 'number' && Number.isSafeInteger(n) && n >= min && n <= max) {
return n;
}
throw new RangeError(`${field} must be an integer in ${String(min)}..${String(max)}`);
}
function normalizeRequiredBool(value: unknown, fallback: boolean, field: string): boolean {
if (value === undefined) {
return fallback;
}
const parsed = parseBoolValue(value);
if (parsed !== undefined) {
return parsed;
}
throw new TypeError(`${field} must be a boolean`);
}
function normalizeBool(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true' || v === '1' || v === 'yes' || v === 'on') {
return true;
}
if (v === 'false' || v === '0' || v === 'no' || v === 'off' || v === '') {
return false;
}
}
return fallback;
}
function parseRequiredIntegerEnv(
raw: unknown,
min: number,
max: number,
field: string,
): number | undefined {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
const n = Number(raw.trim());
if (Number.isSafeInteger(n) && n >= min && n <= max) {
return n;
}
throw new RangeError(`${field} must be an integer in ${String(min)}..${String(max)}`);
}
function parseRequiredBoolEnv(raw: unknown, field: string): boolean | undefined {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
const parsed = parseBoolValue(raw);
if (parsed !== undefined) {
return parsed;
}
throw new TypeError(`${field} must be a boolean`);
}
function parseBoolValue(raw: unknown): boolean | undefined {
if (typeof raw === 'boolean') {
return raw;
}
if (raw === 1) {
return true;
}
if (raw === 0) {
return false;
}
if (typeof raw !== 'string') {
return undefined;
}
const value = raw.trim().toLowerCase();
if (value === 'true' || value === '1' || value === 'yes' || value === 'on') {
return true;
}
if (value === 'false' || value === '0' || value === 'no' || value === 'off') {
return false;
}
return undefined;
}
function isErrnoCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
+28
View File
@@ -0,0 +1,28 @@
import parse from 'semver/functions/parse.js';
/** Parse a canonical SemVer string; tag prefixes belong at the GitHub boundary. */
export function parseVersion(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const parsed = parse(value);
if (!parsed) {
return null;
}
const canonical = parsed.version + (parsed.build.length ? `+${parsed.build.join('.')}` : '');
return value === canonical ? parsed : null;
}
export function compareVersions(a: string, b: string): number {
const left = parseVersion(a);
const right = parseVersion(b);
if (!left || !right) {
throw new TypeError('Invalid semantic version');
}
return left.compare(right);
}
+157
View File
@@ -0,0 +1,157 @@
/** TTL caching and request deduplication to limit repeated upstream requests. */
export interface SingleFlightCacheOptions<K, V> {
/** Freshness window in ms. Expired values are released even while idle. */
ttlMs: number;
/** Load the value for a key. Rejections propagate and are never cached. */
load: (key: K) => Promise<V>;
/**
* Decide whether a resolved value should be cached. Default: always.
* Return `false` to leave an entry uncached (e.g. an error-shaped result the
* next call should retry) while still de-duping concurrent in-flight callers.
*/
shouldCache?: (value: V) => boolean;
/** Clock, injectable for tests. Defaults to `Date.now`. */
now?: () => number;
}
export class SingleFlightCache<K, V> {
private readonly ttlMs: number;
private readonly load: (key: K) => Promise<V>;
private readonly shouldCache: (value: V) => boolean;
private readonly now: () => number;
private readonly fresh = new Map<K, { at: number; value: V }>();
private readonly inflight = new Map<K, Promise<V>>();
private expiryTimer: ReturnType<typeof setTimeout> | undefined;
private disposed = false;
constructor(opts: SingleFlightCacheOptions<K, V>) {
this.ttlMs = opts.ttlMs;
this.load = opts.load;
this.shouldCache = opts.shouldCache ?? (() => true);
this.now = opts.now ?? Date.now;
}
/** Number of retained values; pending loads are not counted. */
get size(): number {
return this.fresh.size;
}
/**
* Get the value for `key`.
*
* - Returns a fresh cached value when the last load was within `ttlMs`.
* - Otherwise de-dupes concurrent callers onto a single `load()` and caches
* the result (subject to `shouldCache`). A rejected `load` propagates to
* every joined caller, clears the in-flight slot, and is not cached.
* - `force` bypasses the freshness check but still joins an in-flight load
* rather than starting a second one.
*/
get(key: K, opts: { force?: boolean } = {}): Promise<V> {
if (this.disposed) {
return Promise.reject(new Error('SingleFlightCache is disposed'));
}
const hit = this.fresh.get(key);
if (hit) {
if (this.now() - hit.at >= this.ttlMs) {
this.fresh.delete(key);
this.cancelExpiryIfEmpty();
} else if (!opts.force) {
return Promise.resolve(hit.value);
}
}
const existing = this.inflight.get(key);
if (existing) {
return existing;
}
const load = async (): Promise<V> => {
const value = await this.load(key);
if (this.inflight.get(key) === task && this.ttlMs > 0 && this.shouldCache(value)) {
// Keep insertion order aligned with expiry, including refreshes.
this.fresh.delete(key);
this.fresh.set(key, { at: this.now(), value });
this.scheduleExpiry();
}
return value;
};
const task = load().finally(() => {
if (this.inflight.get(key) === task) {
this.inflight.delete(key);
}
});
this.inflight.set(key, task);
return task;
}
/** Detach the entry and its pending load; existing callers still settle. */
invalidate(key: K): void {
this.fresh.delete(key);
this.inflight.delete(key);
this.cancelExpiryIfEmpty();
}
/** Detach all entries and pending loads without aborting existing callers. */
clear(): void {
this.fresh.clear();
this.inflight.clear();
this.cancelExpiryIfEmpty();
}
/** Release this owner's cache permanently; pending callers still settle. */
dispose(): void {
this.disposed = true;
this.clear();
}
private cancelExpiryIfEmpty(): void {
if (this.fresh.size === 0 && this.expiryTimer !== undefined) {
clearTimeout(this.expiryTimer);
this.expiryTimer = undefined;
}
}
private scheduleExpiry(): void {
if (this.expiryTimer !== undefined || !Number.isFinite(this.ttlMs)) {
return;
}
const oldest = this.fresh.values().next().value;
if (!oldest) {
return;
}
// One unref'ed timer per cache, not one closure/timer per cached roster.
// Clamp long TTLs to Node's timer range rather than overflowing to 1ms.
const delay = Math.max(1, Math.min(2 ** 31 - 1, oldest.at + this.ttlMs - this.now()));
this.expiryTimer = setTimeout(() => {
this.expiryTimer = undefined;
const now = this.now();
for (const [key, entry] of this.fresh) {
if (now - entry.at < this.ttlMs) {
break;
}
this.fresh.delete(key);
}
this.scheduleExpiry();
}, delay);
this.expiryTimer.unref();
}
}
export function createSingleFlightCache<K, V>(
opts: SingleFlightCacheOptions<K, V>,
): SingleFlightCache<K, V> {
return new SingleFlightCache(opts);
}
+11
View File
@@ -0,0 +1,11 @@
// Shared QQ UIN validity check. A "real" UIN is a live QQ account number, not
// a garbage value the native hook can emit during the probe→inject race — e.g.
// a 13-digit millisecond timestamp read from not-yet-populated QQ memory
// (issue #162). No real QQ UIN exceeds 10 digits (they fit in uint32, max
// 4294967295), so a pure digit string of 5..10 chars is the accept window.
//
// Callers rely on a `true` result guaranteeing a BigInt()-safe pure-digit
// string. Keep that contract if the pattern ever changes.
export function isRealUin(uin: string): boolean {
return /^\d{5,10}$/.test(uin);
}
+60
View File
@@ -0,0 +1,60 @@
export interface UpdateSourceConfig {
mode: 'auto' | 'direct' | 'mirror';
mirror: string;
mirrors: string[];
}
export function normalizeMirrorUrl(value: unknown): string {
if (typeof value !== 'string' || value.length > 2048) {
throw new Error('镜像地址必须是 HTTP(S) URL');
}
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error('镜像地址必须是 HTTP(S) URL');
}
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error('镜像地址仅支持 HTTP(S),不能包含账号、密码、查询参数或片段');
}
return url.href.replace(/\/+$/, '');
}
export function normalizeUpdateSourceConfig(value: unknown): UpdateSourceConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('更新来源必须是对象');
}
if (
!('mode' in value) ||
(value.mode !== 'auto' && value.mode !== 'direct' && value.mode !== 'mirror')
) {
throw new Error('更新来源模式无效');
}
if (!('mirrors' in value) || !Array.isArray(value.mirrors) || value.mirrors.length > 16) {
throw new Error('自动筛选最多支持 16 个镜像');
}
const mirror = 'mirror' in value && value.mirror !== '' ? normalizeMirrorUrl(value.mirror) : '';
if (value.mode === 'mirror' && !mirror) {
throw new Error('请指定镜像地址');
}
return {
mode: value.mode,
mirror,
mirrors: [...new Set(value.mirrors.map(normalizeMirrorUrl))],
};
}
+3
View File
@@ -0,0 +1,3 @@
import manifest from '../../../package.json' with { type: 'json' };
export const APP_VERSION = manifest.version;
+100
View File
@@ -0,0 +1,100 @@
/** Browser-safe contract shared by startup, the WebUI and embedding hosts.
* Targets are deployment choices; they never belong in the saved UI preferences. */
export const WEBUI_TARGETS = {
maibot: {
label: '麦麦 / 一键包',
styles: ['millennium'],
defaultStyle: 'millennium',
},
} as const;
export type WebuiTarget = keyof typeof WEBUI_TARGETS;
export type WebuiColorMode = 'light' | 'dark' | 'system';
export type WebuiPresentation = {
[T in WebuiTarget]: {
version: 1;
target: T;
style: (typeof WEBUI_TARGETS)[T]['styles'][number];
mode: WebuiColorMode;
};
}[WebuiTarget];
/** null releases the override for this document, without changing deployment opt-in. */
export type WebuiPresentationUpdate = WebuiPresentation | { version: 1; target: null };
export const WEBUI_PRESENTATION_EVENT = 'snowluma:host-appearance';
export const WEBUI_TARGET_BOOTSTRAP_ID = 'snowluma-webui-target';
export function resolveWebuiTarget(raw: string | undefined): WebuiTarget | null {
if (raw === undefined || raw.trim() === '') {
return null;
}
const value = raw.trim();
if (!Object.hasOwn(WEBUI_TARGETS, value)) {
throw new Error(
`Invalid SNOWLUMA_WEBUI_TARGET: ${JSON.stringify(value)}. Expected ${Object.keys(WEBUI_TARGETS).join(', ')} or an empty value.`,
);
}
return value as WebuiTarget;
}
export function defaultWebuiPresentation(target: WebuiTarget): WebuiPresentation {
return { version: 1, target, style: WEBUI_TARGETS[target].defaultStyle, mode: 'system' };
}
export function parseWebuiPresentation(value: unknown): WebuiPresentationUpdate {
if (
!value ||
typeof value !== 'object' ||
!('version' in value) ||
value.version !== 1 ||
!('target' in value)
) {
throw new TypeError('Invalid host appearance: expected a version 1 presentation');
}
if (value.target === null) {
return { version: 1, target: null };
}
if (typeof value.target !== 'string' || !Object.hasOwn(WEBUI_TARGETS, value.target)) {
throw new TypeError('Invalid host appearance target');
}
const target = value.target as WebuiTarget;
const styles: readonly string[] = WEBUI_TARGETS[target].styles;
if (!('style' in value) || typeof value.style !== 'string' || !styles.includes(value.style)) {
throw new TypeError(`Invalid host appearance style for ${target}`);
}
if (
!('mode' in value) ||
(value.mode !== 'light' && value.mode !== 'dark' && value.mode !== 'system')
) {
throw new TypeError('Invalid host appearance color mode');
}
// Both discriminants have been checked against the same target registry.
return { version: 1, target, style: value.style, mode: value.mode } as WebuiPresentation;
}
/** Inject before the cached pre-paint theme script. Default HTML stays byte-for-byte intact. */
export function injectWebuiTarget(html: string, target: WebuiTarget | null): string {
if (target === null) {
return html;
}
if (!html.includes('<head>')) {
throw new Error('WebUI HTML is missing <head>; cannot inject runtime target');
}
const json = JSON.stringify(defaultWebuiPresentation(target)).replaceAll('<', '\\u003c');
return html.replace(
'<head>',
`<head>\n <script id="${WEBUI_TARGET_BOOTSTRAP_ID}" type="application/json">${json}</script>`,
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
ACCESS_TOKEN_MIN_LENGTH,
ACCESS_TOKEN_MIN_SCORE,
assessAccessToken,
} from '../src/access-token';
describe('access token strength', () => {
it('requires the configured minimum length', () => {
expect(assessAccessToken('a'.repeat(ACCESS_TOKEN_MIN_LENGTH - 1))).toMatchObject({
acceptable: false,
reason: 'too-short',
});
});
it('scores system and user-specific words as guessable', () => {
expect(
assessAccessToken('SnowLumaSnowLuma', ['SnowLuma', 'OneBot', '10001']),
).toMatchObject({
acceptable: false,
reason: 'guessable',
});
});
it('accepts a sufficiently long, hard-to-guess token', () => {
const result = assessAccessToken('E5xqVb7_9pLt2QwR4M8kZ1nY', ['SnowLuma', 'OneBot']);
expect(result.acceptable).toBe(true);
expect(result.reason).toBe('acceptable');
expect(result.score).toBeGreaterThanOrEqual(ACCESS_TOKEN_MIN_SCORE);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { isObject, boolOr, clampNum, clampInt } from '../src/coerce';
describe('isObject', () => {
it('is true only for plain non-null non-array objects', () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
expect(isObject(null)).toBe(false);
expect(isObject([])).toBe(false);
expect(isObject('x')).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject(undefined)).toBe(false);
});
});
describe('boolOr', () => {
it('returns the value only when it is a real boolean, else the fallback', () => {
expect(boolOr(true, false)).toBe(true);
expect(boolOr(false, true)).toBe(false);
expect(boolOr('true', false)).toBe(false); // strings are NOT coerced
expect(boolOr(1, false)).toBe(false);
expect(boolOr(undefined, true)).toBe(true);
expect(boolOr(null, true)).toBe(true);
});
});
describe('clampNum', () => {
it('passes finite numbers through, clamped to [min,max]', () => {
expect(clampNum(5, 0, 10, -1)).toBe(5);
expect(clampNum(-3, 0, 10, -1)).toBe(0); // below min
expect(clampNum(99, 0, 10, -1)).toBe(10); // above max
});
it('coerces numeric strings', () => {
expect(clampNum('7', 0, 10, -1)).toBe(7);
expect(clampNum('7.5', 0, 10, -1)).toBe(7.5); // NOT truncated (that is clampInt)
});
it('falls back on non-finite / non-numeric input', () => {
expect(clampNum('abc', 0, 10, 3)).toBe(3);
expect(clampNum(NaN, 0, 10, 3)).toBe(3);
expect(clampNum(Infinity, 0, 10, 3)).toBe(3);
expect(clampNum(undefined, 0, 10, 3)).toBe(3);
expect(clampNum(null, 0, 10, 3)).toBe(3);
expect(clampNum({}, 0, 10, 3)).toBe(3);
});
it('preserves fractional precision within bounds', () => {
expect(clampNum(0.42, 0, 1, 0)).toBe(0.42);
});
});
describe('clampInt', () => {
it('is clampNum truncated toward zero', () => {
expect(clampInt(7.9, 0, 10, -1)).toBe(7);
expect(clampInt('7.9', 0, 10, -1)).toBe(7);
expect(clampInt(5, 0, 10, -1)).toBe(5);
});
it('clamps before truncating', () => {
expect(clampInt(99.9, 0, 10, -1)).toBe(10);
expect(clampInt(-3.5, 0, 10, -1)).toBe(0);
});
it('clamp-then-trunc ordering: a fractional lower bound is crossed by the clamp, not the trunc', () => {
// Distinguishes clamp→trunc (this contract) from trunc→clamp: input 2.4
// with min 2.5 → clamp lifts to 2.5, then trunc → 2. A trunc-first impl
// would floor to 2 then clamp up to 2.5. Pins the order for the shared
// primitive (future callers may use fractional bounds).
expect(clampInt(2.4, 2.5, 10, 0)).toBe(2);
});
it('falls back on non-finite input', () => {
expect(clampInt('abc', 0, 10, 4)).toBe(4);
expect(clampInt(NaN, 0, 10, 4)).toBe(4);
expect(clampInt(undefined, 0, 10, 4)).toBe(4);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { mapWithConcurrency } from '../src/concurrency';
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
describe('mapWithConcurrency', () => {
it('returns results in input order', async () => {
const out = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => n * 10);
expect(out).toEqual([10, 20, 30, 40, 50]);
});
it('passes the absolute index (across batch boundaries)', async () => {
const seen: Array<[number, number]> = [];
await mapWithConcurrency(['a', 'b', 'c', 'd', 'e'], 2, async (item, i) => {
seen.push([i, item.charCodeAt(0)]);
return i;
});
expect(seen.map(([i]) => i).sort((x, y) => x - y)).toEqual([0, 1, 2, 3, 4]);
});
it('never runs more than `concurrency` at once (batched), and serializes across batches', async () => {
let inFlight = 0;
let peak = 0;
const order: number[] = [];
await mapWithConcurrency([0, 1, 2, 3, 4, 5, 6], 3, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
order.push(n);
inFlight--;
return n;
});
expect(peak).toBeLessThanOrEqual(3);
// batched: first 3 (0,1,2) settle before the next batch (3,4,5) starts.
expect(order.slice(0, 3).sort((a, b) => a - b)).toEqual([0, 1, 2]);
expect(order.slice(3, 6).sort((a, b) => a - b)).toEqual([3, 4, 5]);
expect(order[6]).toBe(6);
});
it('handles a length not divisible by concurrency (short final batch)', async () => {
const out = await mapWithConcurrency([1, 2, 3, 4, 5], 3, async (n) => n);
expect(out).toEqual([1, 2, 3, 4, 5]);
});
it('runs everything in one batch when concurrency >= length', async () => {
let peak = 0,
inFlight = 0;
const out = await mapWithConcurrency([1, 2, 3], 10, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
inFlight--;
return n;
});
expect(out).toEqual([1, 2, 3]);
expect(peak).toBe(3);
});
it('returns [] for an empty input without calling fn', async () => {
let called = 0;
const out = await mapWithConcurrency([], 4, async (n) => {
called++;
return n;
});
expect(out).toEqual([]);
expect(called).toBe(0);
});
it('treats concurrency < 1 as 1 (fully serial)', async () => {
let peak = 0,
inFlight = 0;
await mapWithConcurrency([1, 2, 3], 0, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
inFlight--;
return n;
});
expect(peak).toBe(1);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { positiveIntEnv } from '../src/env';
describe('positiveIntEnv', () => {
it('returns the fallback when the variable is absent or blank', () => {
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: {} })).toBe(15);
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: ' ' } })).toBe(
15,
);
});
it('reads a positive integer and honors the max', () => {
expect(
positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: '2500' } }),
).toBe(2500);
expect(
positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
max: 65535,
env: { SNOWLUMA_TEST_INT: '80' },
}),
).toBe(80);
});
it('ignores zero, negatives, fractions, and values above the max', () => {
for (const raw of ['0', '-5', '1.5', 'nope', '70000']) {
expect(
positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
max: 65535,
env: { SNOWLUMA_TEST_INT: raw },
}),
).toBe(15);
}
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { fromHex, hexPreview, toHex, toHexUpper } from '../src/hex';
describe('hex', () => {
it('encodes only the selected byte view', () => {
const bytes = Uint8Array.of(0xff, 0x01, 0xab, 0xff);
expect(toHex(bytes.subarray(1, 3))).toBe('01ab');
expect(toHexUpper(Buffer.from(bytes).subarray(1, 3))).toBe('01AB');
});
it('toHex / toHexUpper render bytes as zero-padded hex', () => {
const b = Uint8Array.from([0x00, 0x0f, 0xa0, 0xff]);
expect(toHex(b)).toBe('000fa0ff');
expect(toHexUpper(b)).toBe('000FA0FF');
expect(toHex(Uint8Array.of())).toBe('');
});
it('hexPreview appends "..." only past maxBytes', () => {
const b = Uint8Array.from([1, 2, 3, 4, 5]);
expect(hexPreview(b)).toBe('0102030405'); // under the default 64-byte cap
expect(hexPreview(b, 5)).toBe('0102030405'); // exactly at the cap → no ellipsis
expect(hexPreview(b, 2)).toBe('0102...'); // truncated
expect(hexPreview(b, 0)).toBe('...'); // nothing shown, but bytes remain
});
it('fromHex round-trips toHex and rejects odd-length input', () => {
const b = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
expect(fromHex(toHex(b)).equals(b)).toBe(true);
expect(toHex(fromHex('00ffa0'))).toBe('00ffa0');
expect(() => fromHex('abc')).toThrow();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { sanitizeLogLine } from '../src/log-sanitize';
describe('sanitizeLogLine', () => {
it('removes SGR styling in seven-bit and eight-bit forms', () => {
expect(sanitizeLogLine('\x1b[31mred\x1b[0m / \u009b32mgreen\u009b0m')).toBe('red / green');
});
it('removes OSC hyperlinks without removing their visible label', () => {
expect(sanitizeLogLine('\x1b]8;;https://example.com\x07visible\x1b]8;;\x07')).toBe(
'visible',
);
});
it('removes terminal control-string payloads', () => {
expect(sanitizeLogLine('before\x1bPprivate payload\x1b\\after')).toBe('beforeafter');
});
it('preserves tabs and newlines while removing other controls', () => {
expect(sanitizeLogLine('one\ttwo\nthree\r\x00')).toBe('one\ttwo\nthree');
});
it('does not leak unterminated terminal strings', () => {
expect(sanitizeLogLine('visible\x1b]8;;https://example.com')).toBe('visible');
});
it('preserves long Unicode records and adjacent visible spans', () => {
const body = '消息 🌌 e\u0301\tpacket=0011aabb\n'.repeat(1000);
expect(sanitizeLogLine(body)).toBe(body);
expect(sanitizeLogLine(`\x1b[32m${body}\x1b[0m\x00tail\x1b`)).toBe(`${body}tail`);
});
it.each(['\x1bP', '\x1bX', '\x1b^', '\x1b_', '\u0090', '\u0098', '\u009e', '\u009f'])(
'removes %j control strings between visible spans',
(opener) => {
expect(sanitizeLogLine(`before${opener}private\u009cafter`)).toBe('beforeafter');
expect(sanitizeLogLine(`before${opener}private\x1b\\after`)).toBe('beforeafter');
expect(sanitizeLogLine(`before${opener}private`)).toBe('before');
},
);
it('handles consecutive controls and records consisting only of controls', () => {
expect(sanitizeLogLine('\x00\x1b[31m\u009b0m\r\u007f')).toBe('');
expect(sanitizeLogLine('a\x00\x1b[31m\u009b0m\r\u007fb')).toBe('ab');
expect(sanitizeLogLine('')).toBe('');
});
it('preserves every visible UTF-16 code unit when removing a large hidden payload', () => {
const visible = 'visible 🌌 e\u0301\ud800x\udc00';
expect(sanitizeLogLine(`${visible}\x1b]${'hidden'.repeat(1000)}`)).toBe(visible);
});
it('does not retain discarded control-string payloads through a short visible span', () => {
const moduleUrl = new URL('../src/log-sanitize.ts', import.meta.url).href;
const output = execFileSync(
process.execPath,
[
'--expose-gc',
'--input-type=module',
'-e',
`
import { setImmediate } from 'node:timers/promises';
import { sanitizeLogLine } from ${JSON.stringify(moduleUrl)};
async function used() {
await setImmediate();
global.gc();
global.gc();
return process.memoryUsage().heapUsed;
}
function record(i) {
return sanitizeLogLine('visible record ' + i + '\\x1b]' + 'x'.repeat(1024 * 1024));
}
const before = await used();
const records = Array.from({ length: 16 }, (_, i) => record(i));
const retained = (await used()) - before;
console.log(JSON.stringify({ retained, records }));
`,
],
{ encoding: 'utf8', timeout: 10_000 },
);
const result = JSON.parse(output);
expect(result.records).toEqual(Array.from({ length: 16 }, (_, i) => `visible record ${i}`));
// Leave ample GC/runtime overhead while rejecting retention of the 16 MiB input.
expect(result.retained).toBeLessThan(4 * 1024 * 1024);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import { redactLogMessage, renderParamsVerbose, summarizeParams } from '../src/log-summary';
describe('summarizeParams', () => {
it('renders null/undefined as {}', () => {
expect(summarizeParams(null)).toBe('{}');
expect(summarizeParams(undefined)).toBe('{}');
});
it('renders a top-level non-object via String() WITHOUT quotes', () => {
// The reported change-request: a top-level primitive is NOT JSON-quoted.
expect(summarizeParams('hello')).toBe('hello');
expect(summarizeParams(123)).toBe('123');
});
it('collapses a top-level array to [len=N]', () => {
expect(summarizeParams([1, 2, 3])).toBe('[len=3]');
});
it('quotes string fields, collapses nested values, truncates long strings', () => {
expect(summarizeParams({ a: 1, b: 'x', c: { d: 1 }, e: [1, 2] })).toBe(
'a=1 b="x" c={...} e=[len=2]',
);
expect(summarizeParams({ s: 'x'.repeat(50) })).toBe(`s="${'x'.repeat(40)}..."`);
});
it('redacts authentication fields without exposing their values', () => {
const out = summarizeParams({
token: 'token-value',
Cookie: 'cookie-value',
Authorization: 'Bearer authorization-value',
user_password: 'password-value',
apiKey: 'key-value',
group_id: 12345,
});
expect(out).toContain('token="***"');
expect(out).toContain('Cookie="***"');
expect(out).toContain('Authorization="***"');
expect(out).toContain('user_password="***"');
expect(out).toContain('apiKey="***"');
expect(out).toContain('group_id=12345');
expect(out).not.toMatch(
/token-value|cookie-value|authorization-value|password-value|key-value/,
);
});
it('does not treat sensitive substrings inside ordinary keys as credentials', () => {
expect(
summarizeParams({
retoken: 'visible',
cookietown: 'visible',
myauthorization: 'visible',
}),
).toBe('retoken="visible" cookietown="visible" myauthorization="visible"');
});
it('caps total output and appends "..." on overflow', () => {
const big: Record<string, number> = {};
for (let i = 0; i < 60; i++) {
big['key' + i] = i;
}
const out = summarizeParams(big);
expect(out.startsWith('key0=0')).toBe(true);
expect(out.endsWith('...')).toBe(true);
expect(out.length).toBeLessThanOrEqual(200);
});
it('caps a single oversized first key and top-level primitive', () => {
const oversizedKey = 'k'.repeat(300);
const objectOut = summarizeParams({ [oversizedKey]: 'value' });
const primitiveOut = summarizeParams('x'.repeat(300));
expect(objectOut).toHaveLength(200);
expect(objectOut.endsWith('...')).toBe(true);
expect(primitiveOut).toHaveLength(200);
expect(primitiveOut.endsWith('...')).toBe(true);
});
});
describe('redactLogMessage', () => {
it('redacts explicit authentication assignments without changing prose', () => {
const out = redactLogMessage(
'password=secret Authorization: Bearer bearer-secret cookie="cookie secret" password change required',
);
expect(out).toBe('password=*** Authorization: *** cookie=*** password change required');
});
it('redacts quoted object keys, Basic auth, and complete Cookie values', () => {
const out = redactLogMessage(
`json={"authorization":"Basic json-auth"} object={ 'api-key': 'api-secret' } Authorization: Basic header-auth Cookie=session=cookie-secret; Path=/; HttpOnly`,
);
const expires = redactLogMessage(
'Cookie=session=expires-secret; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly safe=visible',
);
expect(out).toContain('json={"authorization":***}');
expect(out).toContain("object={ 'api-key': *** }");
expect(out).toContain('Authorization: *** Cookie=***');
expect(expires).toBe('Cookie=*** safe=visible');
expect(out).not.toMatch(/json-auth|api-secret|header-auth|cookie-secret|Path=|HttpOnly/);
expect(expires).not.toMatch(/expires-secret|Expires|21 Oct|Path=|HttpOnly/);
});
it('redacts complete multi-parameter Authorization values', () => {
const out = redactLogMessage(
'Authorization: Digest username="alice", realm="qq", response="digest-secret"\n' +
'authorization=AWS4-HMAC-SHA256 Credential=AKID/20260730/cn/service/aws4_request, ' +
'SignedHeaders=host;x-date, Signature=aws-secret\n' +
'Authorization: Bearer bearer-secret Cookie=session=cookie-secret; Path=/',
);
expect(out).toBe('Authorization: ***\nauthorization=***\nAuthorization: *** Cookie=***');
expect(out).not.toMatch(
/alice|realm|digest-secret|AKID|SignedHeaders|aws-secret|bearer-secret|cookie-secret|Path=/,
);
});
it('redacts camelCase authentication keys without matching larger words', () => {
const out = redactLogMessage(
`authToken=auth-secret idToken=id-secret clientSecret=client-secret {"userPassword":"password-secret"}`,
);
expect(out).toBe(`authToken=*** idToken=*** clientSecret=*** {"userPassword":***}`);
expect(out).not.toMatch(/auth-secret|id-secret|client-secret|password-secret/);
expect(redactLogMessage('retoken=value cookietown=value myauthorization=value')).toBe(
'retoken=value cookietown=value myauthorization=value',
);
});
it('redacts leading separator and numeric authentication keys without matching larger words', () => {
expect(
redactLogMessage(
'_token=underscore-secret --password=cli-secret 2faToken=two-factor-secret retoken=visible',
),
).toBe('_token=*** --password=*** 2faToken=*** retoken=visible');
});
it('redacts nested assignments inside non-sensitive wrapper values', () => {
expect(redactLogMessage('params=authToken=auth-secret params=cookie="cookie-secret"')).toBe(
'params=authToken=*** params=cookie=***',
);
});
it('redacts the entire Authorization value when it starts with a marker', () => {
expect(redactLogMessage('Authorization: *** secret-value')).toBe('Authorization: ***');
});
it('redacts property paths and URL query authentication values', () => {
const out = redactLogMessage(
'headers.authorization=Basic dXNlcjpwYXNz ' +
'request.token=plain-token ' +
'GET /callback?access_token=query-secret&x=1 ' +
'url=https://host/?api_key=url-secret',
);
expect(out).toContain('headers.authorization=***');
expect(out).toContain('request.token=***');
expect(out).toContain('/callback?access_token=***&x=1');
expect(out).toContain('https://host/?api_key=***');
expect(out).not.toMatch(/dXNlcjpwYXNz|plain-token|query-secret|url-secret/);
});
it('redacts prefixed authentication keys without matching larger words', () => {
const redacted = redactLogMessage(
'x-api-key=api-secret user_password=password-secret ' +
'headers.authorization=Basic auth-secret ' +
'x-cookie="session=cookie-secret"; Path=/; HttpOnly',
);
expect(redacted).toContain('x-api-key=***');
expect(redacted).toContain('user_password=***');
expect(redacted).toContain('headers.authorization=***');
expect(redacted).toContain('x-cookie=***');
expect(redacted).not.toMatch(
/api-secret|password-secret|auth-secret|cookie-secret|Path=|HttpOnly/,
);
expect(redactLogMessage('retoken=value cookietown=value myauthorization=value')).toBe(
'retoken=value cookietown=value myauthorization=value',
);
});
});
describe('renderParamsVerbose', () => {
it('keeps authentication fields unredacted for complete TRACE diagnostics', () => {
const out = renderParamsVerbose({
token: 'token-value',
Cookie: 'cookie-value',
password: 'password-value',
apiKey: 'key-value',
});
expect(out).toContain('token:"token-value"');
expect(out).toContain('Cookie:"cookie-value"');
expect(out).toContain('password:"password-value"');
expect(out).toContain('apiKey:"key-value"');
expect(out).not.toContain('"***"');
});
it('does not truncate TRACE values or their enclosing object', () => {
const unicodeValue = '雪'.repeat(800);
const out = renderParamsVerbose({ blob: unicodeValue, tail: 'complete' });
expect(out).toContain(`blob:${JSON.stringify(unicodeValue)}`);
expect(out).toContain('tail:"complete"');
expect(out).not.toMatch(/…<\d+B>|\.\.\./);
});
it('guards against circular references', () => {
const o: Record<string, unknown> = { a: 1 };
o.self = o;
expect(renderParamsVerbose(o)).toContain('"[circular]"');
});
it('renders shared non-cyclic references in every branch', () => {
const shared = { value: 1 };
expect(renderParamsVerbose({ left: shared, right: shared })).toBe(
'{left:{value:1},right:{value:1}}',
);
});
it('renders nested structure with primitives intact', () => {
expect(renderParamsVerbose({ a: 1, b: [true, 'x'] })).toBe('{a:1,b:[true,"x"]}');
});
});
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { fileWrite } = vi.hoisted(() => ({ fileWrite: vi.fn() }));
vi.mock('../src/log-file-transport', () => ({
getFileTransport: () => ({ write: fileWrite, close: async () => {} }),
}));
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.resetModules();
fileWrite.mockClear();
});
async function setup() {
vi.stubEnv('SNOWLUMA_LOG_LEVEL', 'trace');
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'error');
vi.stubEnv('SNOWLUMA_TRACE_BUFFER', '100');
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
const logger = await import('../src/logger');
return { ...logger, log: logger.createLogger('Snapshot') };
}
describe('retained log snapshots', () => {
it('returns empty snapshots before any record has been emitted', async () => {
const logger = await setup();
expect(logger.getRecentLogs()).toEqual([]);
expect(logger.getLogSnapshot()).toEqual([]);
});
it('merges wrapped rings in emission order and returns only the requested tail', async () => {
const logger = await setup();
const emitted: import('../src/logger').LogEntry[] = [];
const unsubscribe = logger.subscribeLogs((entry) => emitted.push(entry));
for (let i = 0; i < 1100; i += 1) {
logger.log.info('normal %d', i);
logger.log.trace('trace %d', i);
}
unsubscribe();
const normal = emitted.filter((entry) => entry.level !== 'trace').slice(-1000);
const trace = emitted.filter((entry) => entry.level === 'trace').slice(-100);
const expected = [...normal, ...trace].sort((a, b) => a.id - b.id);
expect(logger.getLogSnapshot()).toEqual(expected);
for (const limit of [1, 2, 30, 300, 1000, 1100, 2000, 0, -1, 2.9, NaN, Infinity]) {
expect(logger.getRecentLogs(limit)).toEqual(
expected.slice(-Math.max(1, Math.trunc(limit))),
);
}
expect(logger.getRecentLogs()).toEqual(expected.slice(-300));
const snapshot = logger.getRecentLogs(5);
snapshot.pop();
expect(logger.getRecentLogs(5)).toEqual(expected.slice(-5));
});
it.each(['info', 'trace'] as const)(
'reads a tail when only the %s ring has records',
async (level) => {
const logger = await setup();
logger.log[level]('first');
logger.log[level]('second');
expect(logger.getRecentLogs(1).map((entry) => entry.message)).toEqual(['second']);
expect(logger.getLogSnapshot().map((entry) => entry.message)).toEqual([
'first',
'second',
]);
},
);
it('preserves ID gaps and file routing without publishing file-only entries', async () => {
const logger = await setup();
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'debug');
vi.resetModules();
const fresh = await import('../src/logger');
fresh.setLogLevel('info');
const entries: import('../src/logger').LogEntry[] = [];
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
const log = fresh.createLogger('FileOnly').child({ uin: 10001 });
log.info('before');
fresh.runWithRequestId(42, () => log.debug('password=secret file-only\x07'));
log.info('after');
unsubscribe();
expect(entries.map((entry) => entry.message)).toEqual(['before', 'after']);
expect(entries[1]!.id).toBe(entries[0]!.id + 2);
expect(fresh.getLogSnapshot()).toEqual(entries);
expect(fileWrite).toHaveBeenCalledWith(
expect.stringMatching(/\[req#42\] password=\*\*\* file-only/),
10001,
);
expect(process.stdout.write).toHaveBeenCalledTimes(2);
expect(logger.getLogSnapshot()).toEqual([]);
});
});
+338
View File
@@ -0,0 +1,338 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { externalFileLevel, externalLogLevel, fileWriteSpy } = vi.hoisted(() => {
const savedFileLevel = process.env.SNOWLUMA_LOG_FILE_LEVEL;
const savedLogLevel = process.env.SNOWLUMA_LOG_LEVEL;
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
delete process.env.SNOWLUMA_LOG_LEVEL;
return {
externalFileLevel: savedFileLevel,
externalLogLevel: savedLogLevel,
fileWriteSpy: vi.fn(),
};
});
// Capture file transport writes via a spy so tests can assert on what reaches
// the file.
vi.mock('../src/log-file-transport', () => ({
getFileTransport: () => ({ write: fileWriteSpy, close: async () => {} }),
}));
import { createLogger, subscribeLogs, type LogEntry } from '../src/logger';
// Regression for issue #162: a hook-reported garbage UIN (13-digit, timestamp
// shaped) produced a `[…]` tag wider than the fixed UIN slot, and the colored
// render path did `' '.repeat(slot - tagLen)` → RangeError: Invalid count value
// → uncaughtException. The padding must clamp at zero.
describe('logger UIN slot padding', () => {
let prevTTY: boolean | undefined;
let prevNoColor: string | undefined;
beforeEach(() => {
prevTTY = process.stdout.isTTY;
prevNoColor = process.env.NO_COLOR;
// Force the colored render path (the only one that used .repeat()).
(process.stdout as unknown as { isTTY: boolean }).isTTY = true;
delete process.env.NO_COLOR;
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
});
afterEach(() => {
(process.stdout as unknown as { isTTY: boolean | undefined }).isTTY = prevTTY;
if (prevNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = prevNoColor;
}
vi.restoreAllMocks();
});
it('does not throw when the UIN tag exceeds the slot width', () => {
const log = createLogger('Test').child({ uin: '1701414379536' }); // 13-digit → [..] = 15 chars
expect(() => log.info('phantom account line')).not.toThrow();
expect(() => log.error('phantom error line')).not.toThrow();
});
it('still renders a normal-width UIN and a no-UIN logger', () => {
expect(() => createLogger('Test').child({ uin: '10001' }).info('ok')).not.toThrow();
expect(() => createLogger('Test').info('no uin')).not.toThrow();
});
it('does not throw when the console stream cannot accept the line', () => {
vi.mocked(process.stderr.write).mockImplementation(() => {
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
});
expect(() => createLogger('Test').error('disk is full')).not.toThrow();
});
it('keeps structured subscriber lines plain while terminal output stays colored', () => {
const captured: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
createLogger('WebUI.Export').info('download me');
unsubscribe();
expect(captured).toHaveLength(1);
expect(captured[0]!.line).toMatch(/INFO\s+\[WebUI\.Export\] download me$/);
expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining('\x1b['));
});
});
// ─── Independent file-level filtering ─────────────────────────────────────
const SAVED_FILE_LEVEL = externalFileLevel;
async function loadLoggerForFileLevel(level?: string) {
if (level === undefined) {
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
} else {
process.env.SNOWLUMA_LOG_FILE_LEVEL = level;
}
vi.resetModules();
fileWriteSpy.mockClear();
return import('../src/logger');
}
afterEach(() => {
if (SAVED_FILE_LEVEL === undefined) {
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
} else {
process.env.SNOWLUMA_LOG_FILE_LEVEL = SAVED_FILE_LEVEL;
}
vi.resetModules();
fileWriteSpy.mockClear();
});
afterAll(() => {
if (SAVED_FILE_LEVEL === undefined) {
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
} else {
process.env.SNOWLUMA_LOG_FILE_LEVEL = SAVED_FILE_LEVEL;
}
if (externalLogLevel === undefined) {
delete process.env.SNOWLUMA_LOG_LEVEL;
} else {
process.env.SNOWLUMA_LOG_LEVEL = externalLogLevel;
}
});
describe('file output gating', () => {
const cases = [
{ level: undefined, expected: ['debug', 'info', 'success', 'warn', 'error'] },
{ level: 'debug', expected: ['debug', 'info', 'success', 'warn', 'error'] },
{ level: 'info', expected: ['info', 'success', 'warn', 'error'] },
{ level: 'success', expected: ['success', 'warn', 'error'] },
{ level: 'warn', expected: ['warn', 'error'] },
{ level: 'error', expected: ['error'] },
] as const;
for (const testCase of cases) {
it(`writes exactly the enabled levels for ${testCase.level ?? 'the default'}`, async () => {
const fresh = await loadLoggerForFileLevel(testCase.level);
const log = fresh.createLogger('Test');
fresh.setLogLevel('trace');
log.trace('trace');
log.debug('debug');
log.info('info');
log.success('success');
log.warn('warn');
log.error('error');
const messages = fileWriteSpy.mock.calls.map(([line]) =>
String(line).split('] ').at(-1),
);
expect(messages).toEqual(testCase.expected);
});
}
it('accepts surrounding whitespace and mixed case', async () => {
const fresh = await loadLoggerForFileLevel(' INFO ');
const log = fresh.createLogger('Test');
log.debug('debug');
log.info('info');
expect(fileWriteSpy).toHaveBeenCalledTimes(1);
expect(fileWriteSpy).toHaveBeenCalledWith(expect.stringContaining('info'), undefined);
});
it.each(['trace', 'verbose', 'erorr'])(
'rejects unsupported file level %s at startup',
async (level) => {
await expect(loadLoggerForFileLevel(level)).rejects.toThrow(
'SNOWLUMA_LOG_FILE_LEVEL must be one of: debug, info, success, warn, error',
);
expect(fileWriteSpy).not.toHaveBeenCalled();
},
);
it('does not let the runtime console level change file output', async () => {
const fresh = await loadLoggerForFileLevel('info');
const log = fresh.createLogger('Test');
fresh.setLogLevel('error');
log.info('persisted-info');
fresh.setLogLevel('trace');
log.debug('filtered-debug');
expect(fileWriteSpy).toHaveBeenCalledTimes(1);
expect(fileWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('persisted-info'),
undefined,
);
});
it('skips rendering when both console and file filters reject a record', async () => {
const fresh = await loadLoggerForFileLevel('error');
fresh.setLogLevel('error');
const rendered = vi.fn(() => 'expensive');
const value = { [Symbol.toPrimitive]: rendered };
fresh.createLogger('Test').debug('value=%s', value);
expect(rendered).not.toHaveBeenCalled();
expect(fileWriteSpy).not.toHaveBeenCalled();
});
it('keeps trace out of files in an explicit file-threshold check', async () => {
const fresh = await loadLoggerForFileLevel('debug');
fresh.setLogLevel('trace');
fresh.createLogger('Test').trace('full chain');
expect(fileWriteSpy).not.toHaveBeenCalled();
});
it('redacts authentication assignments from ordinary logs but not TRACE', async () => {
const fresh = await loadLoggerForFileLevel('debug');
fresh.setLogLevel('trace');
const entries: LogEntry[] = [];
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
const log = fresh.createLogger('Test');
log.info(
'password=%s Authorization: Bearer %s Cookie=%s token=%s safe=%s',
'ordinary-password',
'ordinary-authorization',
'ordinary-cookie',
'ordinary-token',
'visible',
);
log.trace(
'password=%s Authorization: Bearer %s Cookie=%s token=%s',
'trace-password',
'trace-authorization',
'trace-cookie',
'trace-token',
);
log.info(
`json={"authorization":"Basic json-auth"} object={ 'api-key': 'api-secret' } Authorization: Basic header-auth Cookie=session=cookie-secret; Path=/; HttpOnly`,
);
log.info(
'headers.authorization=Basic dotted-auth request.token=dotted-token ' +
'GET /callback?access_token=query-secret&x=1 ' +
'url=https://host/?api_key=url-secret',
);
log.info(
'Authorization: Digest username="alice", response="digest-secret"\n' +
'authorization=AWS4-HMAC-SHA256 Credential=AKID, Signature=aws-secret',
);
log.info(
'authToken=camel-auth idToken=camel-id clientSecret=camel-client ' +
`json={"userPassword":"camel-password"}`,
);
log.info('_token=underscore-secret --password=cli-secret 2faToken=two-factor-secret');
log.info('Authorization: *** marker-secret');
log.trace(
`json={"authorization":"Basic trace-json-auth"} Authorization: Basic trace-header-auth Cookie=session=trace-cookie; Path=/`,
);
unsubscribe();
const ordinaryEntries = entries.filter((entry) => entry.level === 'info');
const ordinary = ordinaryEntries.map((entry) => entry.message).join('\n');
expect(ordinary).toContain('password=***');
expect(ordinary).toContain('Authorization: ***');
expect(ordinary).toContain('Cookie=***');
expect(ordinary).toContain('token=***');
expect(ordinary).toContain('headers.authorization=***');
expect(ordinary).toContain('request.token=***');
expect(ordinary).toContain('/callback?access_token=***&x=1');
expect(ordinary).toContain('https://host/?api_key=***');
expect(ordinary).toContain('Authorization: ***\nauthorization=***');
expect(ordinary).toContain('authToken=***');
expect(ordinary).toContain('idToken=***');
expect(ordinary).toContain('clientSecret=***');
expect(ordinary).toContain(`json={"userPassword":***}`);
expect(ordinary).toContain('_token=***');
expect(ordinary).toContain('--password=***');
expect(ordinary).toContain('2faToken=***');
expect(ordinary).toContain('Authorization: ***');
expect(ordinary).toContain('safe=visible');
expect(ordinary).not.toMatch(
/ordinary-password|ordinary-authorization|ordinary-cookie|ordinary-token|json-auth|api-secret|header-auth|cookie-secret|dotted-auth|dotted-token|query-secret|url-secret|digest-secret|AKID|aws-secret|camel-auth|camel-id|camel-client|camel-password|underscore-secret|cli-secret|two-factor-secret|marker-secret/,
);
const trace = entries
.filter((entry) => entry.level === 'trace')
.map((entry) => entry.message)
.join('\n');
expect(trace).toContain('trace-password');
expect(trace).toContain('trace-authorization');
expect(trace).toContain('trace-cookie');
expect(trace).toContain('trace-token');
expect(trace).toContain('trace-json-auth');
expect(trace).toContain('trace-header-auth');
const persisted = fileWriteSpy.mock.calls.map(([line]) => String(line)).join('\n');
expect(persisted).not.toMatch(
/ordinary-password|ordinary-authorization|ordinary-cookie|ordinary-token|json-auth|api-secret|header-auth|cookie-secret|dotted-auth|dotted-token|query-secret|url-secret|digest-secret|AKID|aws-secret|trace-password|trace-json-auth/,
);
});
it('returns the complete retained normal and TRACE snapshot in record order', async () => {
const fresh = await loadLoggerForFileLevel('debug');
const log = fresh.createLogger('Export');
fresh.setLogLevel('trace');
log.info('normal-before');
log.trace('trace-hex=001122aabbcc');
log.warn('normal-after');
expect(fresh.getRecentLogs(1).map((entry) => entry.message)).toEqual(['normal-after']);
expect(fresh.getLogSnapshot().map((entry) => entry.message)).toEqual([
'normal-before',
'trace-hex=001122aabbcc',
'normal-after',
]);
});
it('prints bootstrap credentials only to the current process terminal', async () => {
const fresh = await loadLoggerForFileLevel('error');
fresh.setLogLevel('error');
const entries: LogEntry[] = [];
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
const snapshotBefore = fresh.getLogSnapshot();
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
try {
fresh.logInitialWebuiCredentials('secret');
expect(stdout).toHaveBeenCalledWith(
expect.stringContaining('initial credentials: user=admin password=secret'),
);
expect(entries).toEqual([]);
expect(fresh.getLogSnapshot()).toEqual(snapshotBefore);
expect(fileWriteSpy).not.toHaveBeenCalled();
} finally {
unsubscribe();
stdout.mockRestore();
}
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { currentRequestId, nextRequestId, runWithRequestId } from '../src/request-context';
describe('request-context', () => {
it('nextRequestId increments monotonically and stays a positive integer', () => {
const a = nextRequestId();
const b = nextRequestId();
expect(a).toBeGreaterThan(0);
expect(b).toBe(a + 1);
});
it('runWithRequestId binds the id across sync + async, with nested scopes restoring', async () => {
expect(currentRequestId()).toBeUndefined();
runWithRequestId(42, () => {
expect(currentRequestId()).toBe(42);
runWithRequestId(99, () => {
expect(currentRequestId()).toBe(99);
});
expect(currentRequestId()).toBe(42); // outer scope restored after the nested one
});
expect(currentRequestId()).toBeUndefined(); // cleared outside any scope
await runWithRequestId(7, async () => {
await Promise.resolve();
expect(currentRequestId()).toBe(7); // survives awaits in the same async chain
});
});
it('returns the callback result', () => {
expect(runWithRequestId(1, () => 'value')).toBe('value');
});
});
+229
View File
@@ -0,0 +1,229 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { normalizeRuntimeConfig, resolveRuntimeEnvOverrides } from '../src/runtime';
describe('normalizeRuntimeConfig', () => {
it('applies all defaults for an empty object', () => {
expect(normalizeRuntimeConfig({})).toEqual({
webuiPort: 5099,
hookAutoLoad: false,
webuiHost: '127.0.0.1',
webuiTls: { enabled: false },
trustProxy: '',
logMaxTotalMb: 1024,
logRetainDays: 7,
logPerUin: false,
});
});
it('passes through valid values', () => {
expect(
normalizeRuntimeConfig({
webuiPort: 8080,
hookAutoLoad: true,
webuiHost: '127.0.0.1',
webuiTls: { enabled: true },
trustProxy: '1',
logMaxTotalMb: 2048,
logRetainDays: 0,
logPerUin: true,
}),
).toEqual({
webuiPort: 8080,
hookAutoLoad: true,
webuiHost: '127.0.0.1',
webuiTls: { enabled: true },
trustProxy: '1',
logMaxTotalMb: 2048,
logRetainDays: 0,
logPerUin: true,
});
});
it('falls back on invalid types', () => {
const out = normalizeRuntimeConfig({
webuiPort: 0, // out of range → default
webuiHost: 123, // non-string → default
webuiTls: 'yes', // non-object → default
trustProxy: 5, // non-string → default
});
expect(out.webuiPort).toBe(5099);
expect(out.webuiHost).toBe('127.0.0.1');
expect(out.webuiTls).toEqual({ enabled: false });
expect(out.trustProxy).toBe('');
});
it('fails fast on invalid persisted log settings', () => {
expect(() => normalizeRuntimeConfig({ logMaxTotalMb: 0 })).toThrow(/logMaxTotalMb/);
expect(() => normalizeRuntimeConfig({ logRetainDays: -1 })).toThrow(/logRetainDays/);
expect(() => normalizeRuntimeConfig({ logPerUin: 'maybe' })).toThrow(/logPerUin/);
expect(() => normalizeRuntimeConfig({ logPerUin: '' })).toThrow(/logPerUin/);
expect(() => normalizeRuntimeConfig({ logMaxTotalMb: Number.MAX_SAFE_INTEGER })).toThrow(
/logMaxTotalMb/,
);
expect(() => normalizeRuntimeConfig({ logRetainDays: Number.MAX_SAFE_INTEGER })).toThrow(
/logRetainDays/,
);
});
it('coerces webuiTls.enabled loosely and trims a blank host to default', () => {
expect(normalizeRuntimeConfig({ webuiTls: { enabled: 1 } }).webuiTls).toEqual({
enabled: true,
});
expect(normalizeRuntimeConfig({ webuiHost: ' ' }).webuiHost).toBe('127.0.0.1');
});
it('rejects a non-object input back to full defaults', () => {
expect(normalizeRuntimeConfig(null).webuiPort).toBe(5099);
expect(normalizeRuntimeConfig('nope').webuiHost).toBe('127.0.0.1');
});
});
describe('resolveRuntimeEnvOverrides', () => {
it('returns empty when no SNOWLUMA_* vars are set', () => {
expect(resolveRuntimeEnvOverrides({})).toEqual({});
});
it('parses port / host / trustProxy from env', () => {
expect(
resolveRuntimeEnvOverrides({
SNOWLUMA_WEBUI_PORT: '6700',
SNOWLUMA_WEBUI_HOST: '127.0.0.1',
SNOWLUMA_WEBUI_TRUST_PROXY: '1',
}),
).toEqual({ webuiPort: 6700, webuiHost: '127.0.0.1', trustProxy: '1' });
});
it('parses log storage overrides including retainDays=0', () => {
expect(
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_MAX_TOTAL_MB: '4096',
SNOWLUMA_LOG_RETAIN_DAYS: '0',
SNOWLUMA_LOG_PER_UIN: '1',
}),
).toEqual({
logMaxTotalMb: 4096,
logRetainDays: 0,
logPerUin: true,
});
});
it('fails fast on invalid log storage overrides', () => {
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_MAX_TOTAL_MB: '0',
}),
).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_RETAIN_DAYS: '-1',
}),
).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_PER_UIN: 'maybe',
}),
).toThrow(/SNOWLUMA_LOG_PER_UIN/);
});
it('fails fast when converted log storage overrides would be unsafe', () => {
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_MAX_TOTAL_MB: String(Number.MAX_SAFE_INTEGER),
}),
).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_RETAIN_DAYS: String(Number.MAX_SAFE_INTEGER),
}),
).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
});
it('parses SNOWLUMA_WEBUI_TLS over the saved switch', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '1' })).toEqual({
webuiTls: { enabled: true },
});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '0' })).toEqual({
webuiTls: { enabled: false },
});
expect(() => resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: 'maybe' })).toThrow(
/SNOWLUMA_WEBUI_TLS/,
);
});
it('ignores an out-of-range / non-numeric port env', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: '0' })).toEqual({});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: 'abc' })).toEqual({});
});
it('treats trustProxy="0"/"off" as a real override (not absent)', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TRUST_PROXY: '0' })).toEqual({
trustProxy: '0',
});
});
});
describe('updateRuntimeConfig (fs)', () => {
let prevCwd: string;
let dir: string;
let prevPortEnv: string | undefined;
beforeEach(async () => {
const fs = await import('fs');
const os = await import('os');
const path = await import('path');
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sl-rt-'));
prevCwd = process.cwd();
process.chdir(dir);
prevPortEnv = process.env.SNOWLUMA_WEBUI_PORT;
delete process.env.SNOWLUMA_WEBUI_PORT;
});
afterEach(async () => {
process.chdir(prevCwd);
if (prevPortEnv === undefined) {
delete process.env.SNOWLUMA_WEBUI_PORT;
} else {
process.env.SNOWLUMA_WEBUI_PORT = prevPortEnv;
}
fs.rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('merges a patch over on-disk values and persists normalized', async () => {
const { updateRuntimeConfig } = await import('../src/runtime');
const fs = await import('fs');
const path = await import('path');
const out = updateRuntimeConfig({ webuiHost: '127.0.0.1', webuiTls: { enabled: true } });
expect(out.webuiHost).toBe('127.0.0.1');
expect(out.webuiTls).toEqual({ enabled: true });
const onDisk = JSON.parse(fs.readFileSync(path.join('config', 'runtime.json'), 'utf8'));
expect(onDisk.webuiHost).toBe('127.0.0.1');
expect(onDisk.webuiTls).toEqual({ enabled: true });
});
it('does NOT bake an env-overridden port into the persisted file', async () => {
const { updateRuntimeConfig } = await import('../src/runtime');
const fs = await import('fs');
const path = await import('path');
process.env.SNOWLUMA_WEBUI_PORT = '9999';
updateRuntimeConfig({ webuiHost: '127.0.0.1' });
const onDisk = JSON.parse(fs.readFileSync(path.join('config', 'runtime.json'), 'utf8'));
expect(onDisk.webuiPort).toBe(5099); // default on-disk, NOT the env 9999
});
it('keeps the previous runtime file intact when atomic replacement fails', async () => {
const { updateRuntimeConfig } = await import('../src/runtime');
updateRuntimeConfig({ logRetainDays: 7 });
const runtimePath = path.join('config', 'runtime.json');
const before = fs.readFileSync(runtimePath, 'utf8');
vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
throw Object.assign(new Error('disk replacement failed'), { code: 'EIO' });
});
expect(() => updateRuntimeConfig({ logRetainDays: 0 })).toThrow(/disk replacement failed/);
expect(fs.readFileSync(runtimePath, 'utf8')).toBe(before);
expect(fs.readdirSync('config').filter((name) => name.includes('.tmp-'))).toEqual([]);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { compareVersions, parseVersion } from '../src/semver';
describe('parseVersion', () => {
it.each([
'0.0.0',
'1.2.3',
'1.2.3-rc.1',
'1.2.3-alpha-beta',
'1.2.3+001.build',
'1.2.3-rc.1+build.2',
])('accepts canonical SemVer and preserves its full value: %s', (version) => {
expect(parseVersion(version)?.raw).toBe(version);
});
it.each([
undefined,
null,
123,
{},
'',
'1.2',
'1.2.3.4',
'1.2.x',
'01.2.3',
'1.02.3',
'1.2.03',
'1.2.3-01',
'1.2.3-rc.01',
'1.2.3-rc..1',
'1.2.3-rc_1',
'1.2.3+build..1',
'1.2.3+build_1',
'v1.2.3',
'V1.2.3',
'=1.2.3',
' 1.2.3',
'1.2.3\n',
])('rejects noncanonical input without coercion: %j', (version) => {
expect(parseVersion(version)).toBeNull();
});
});
describe('compareVersions', () => {
it.each([
['1.2.3', '1.2.3', 0],
['1.2.4', '1.2.3', 1],
['1.2.3', '1.2.4', -1],
['1.10.0', '1.9.0', 1],
['2.0.0', '1.9.9', 1],
['3.0.0', '1.0.0', 1],
['1.0.0', '3.0.0', -1],
['1.2.3', '1.2.3-rc.1', 1],
['1.2.3-rc.1', '1.2.3', -1],
['1.2.3-rc.1', '1.2.3-rc.1', 0],
['1.2.3-rc.1', '1.2.3-rc.2', -1],
['1.2.3-rc.2', '1.2.3-rc.1', 1],
['1.2.3-rc.10', '1.2.3-rc.2', 1],
['1.2.3-rc.1+build.2', '1.2.3-rc.1+build.1', 0],
['1.2.3-alpha-beta', '1.2.3-alpha', 1],
['2.0.0-rc.1', '1.9.9', 1],
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
expect(compareVersions(a, b)).toBe(expected);
});
it.each(['1.2', '1.2.3.9', '1.2.x', '', 'v1.2.3', 'V1.2.3', '01.2.3', '1.2.3-rc.01'])(
'rejects invalid versions instead of guessing: %j',
(version) => {
expect(() => compareVersions(version, '1.2.3')).toThrow();
expect(() => compareVersions('1.2.3', version)).toThrow();
},
);
});
@@ -0,0 +1,327 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createSingleFlightCache } from '../src/single-flight-cache';
/** A promise whose resolve/reject is exposed, for driving single-flight races. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('SingleFlightCache', () => {
afterEach(() => {
vi.useRealTimers();
});
it('disposes retained values and rejects new reads without restarting loads or timers', async () => {
vi.useFakeTimers();
const load = vi.fn(async (key: string) => ({ key }));
const cache = createSingleFlightCache({ ttlMs: 300_000, load });
await cache.get('first');
await cache.get('second');
expect(vi.getTimerCount()).toBe(1);
cache.dispose();
cache.dispose();
cache.clear();
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
await expect(cache.get('first')).rejects.toThrow('disposed');
await expect(cache.get('new', { force: true })).rejects.toThrow('disposed');
expect(load).toHaveBeenCalledTimes(2);
});
it.each(['resolve', 'reject'] as const)(
'preserves a pending %s after disposal without retaining the result',
async (outcome) => {
vi.useFakeTimers();
const pending = deferred<string>();
const cache = createSingleFlightCache({ ttlMs: 300_000, load: () => pending.promise });
const first = cache.get('key');
expect(cache.get('key')).toBe(first);
cache.dispose();
if (outcome === 'resolve') {
pending.resolve('late value');
await expect(first).resolves.toBe('late value');
} else {
pending.reject(new Error('late failure'));
await expect(first).rejects.toThrow('late failure');
}
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
},
);
it('releases expired values while idle without expiring newer values early', async () => {
vi.useFakeTimers();
const load = vi.fn(async (key: string) => ({ key }));
const cache = createSingleFlightCache({ ttlMs: 1000, load });
await cache.get('old');
await vi.advanceTimersByTimeAsync(500);
const newer = await cache.get('new');
expect(cache.size).toBe(2);
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(1);
expect(await cache.get('new')).toBe(newer);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it('keeps a refreshed entry until its new deadline and expires other keys', async () => {
vi.useFakeTimers();
const cache = createSingleFlightCache({ ttlMs: 1000, load: async (key: string) => key });
await cache.get('refreshed');
await cache.get('old');
await vi.advanceTimersByTimeAsync(500);
await cache.get('refreshed', { force: true });
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(1);
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(0);
});
it.each(['invalidate', 'clear'] as const)(
'%s cancels idle cleanup when the last value is removed',
async (operation) => {
vi.useFakeTimers();
const cache = createSingleFlightCache({ ttlMs: 1000, load: async () => 'value' });
await cache.get('key');
expect(vi.getTimerCount()).toBe(1);
cache[operation]('key');
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
await cache.get('key');
await vi.advanceTimersByTimeAsync(1000);
expect(cache.size).toBe(0);
},
);
it('does not let expiration detach a pending refresh', async () => {
vi.useFakeTimers();
const pending = deferred<string>();
const load = vi.fn().mockResolvedValueOnce('old').mockReturnValue(pending.promise);
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
await cache.get('key');
const refresh = cache.get('key', { force: true });
await vi.advanceTimersByTimeAsync(1000);
expect(cache.size).toBe(0);
expect(cache.get('key')).toBe(refresh);
pending.resolve('new');
await expect(refresh).resolves.toBe('new');
expect(cache.size).toBe(1);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1000);
expect(cache.size).toBe(0);
});
it('does not retain values when caching is disabled by a zero TTL', async () => {
vi.useFakeTimers();
const cache = createSingleFlightCache({ ttlMs: 0, load: async () => 'value' });
await expect(cache.get('key')).resolves.toBe('value');
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it.each(['invalidate', 'clear'] as const)(
'%s detaches pending loads from the cache',
async (operation) => {
const old = deferred<string>();
const current = deferred<string>();
const load = vi
.fn()
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
const first = cache.get('key');
cache[operation]('key');
const second = cache.get('key');
expect(load).toHaveBeenCalledTimes(2);
old.resolve('old');
await expect(first).resolves.toBe('old');
expect(cache.get('key')).toBe(second);
current.resolve('current');
await expect(second).resolves.toBe('current');
await expect(cache.get('key')).resolves.toBe('current');
expect(load).toHaveBeenCalledTimes(2);
},
);
it('does not let a late invalidated result overwrite a newer value', async () => {
const old = deferred<string>();
const load = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue('current');
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
const first = cache.get('key');
cache.invalidate('key');
await expect(cache.get('key')).resolves.toBe('current');
old.resolve('old');
await first;
await expect(cache.get('key')).resolves.toBe('current');
});
it('caches within the TTL and reloads once expired', async () => {
let clock = 1000;
const load = vi.fn(async (k: string) => `${k}@${clock}`);
const cache = createSingleFlightCache<string, string>({
ttlMs: 100,
load,
now: () => clock,
});
expect(await cache.get('a')).toBe('a@1000');
clock = 1050; // still fresh (< 100)
expect(await cache.get('a')).toBe('a@1000');
expect(load).toHaveBeenCalledTimes(1);
clock = 1101; // 101ms since store → expired
expect(await cache.get('a')).toBe('a@1101');
expect(load).toHaveBeenCalledTimes(2);
});
it('de-dupes concurrent callers onto a single load (single-flight)', async () => {
const d = deferred<string>();
const load = vi.fn(() => d.promise);
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
const p1 = cache.get('k');
const p2 = cache.get('k');
const p3 = cache.get('k');
expect(load).toHaveBeenCalledTimes(1); // only one load in flight
d.resolve('v');
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
expect(load).toHaveBeenCalledTimes(1);
});
it('keys independently — different keys load separately', async () => {
const load = vi.fn(async (k: number) => k * 2);
const cache = createSingleFlightCache<number, number>({ ttlMs: 1000, load });
expect(await cache.get(1)).toBe(2);
expect(await cache.get(2)).toBe(4);
expect(load).toHaveBeenCalledTimes(2);
});
it('force bypasses the freshness check but still joins an in-flight load', async () => {
let clock = 0;
const load = vi.fn(async (k: string) => `${k}@${clock}`);
const cache = createSingleFlightCache<string, string>({
ttlMs: 10_000,
load,
now: () => clock,
});
expect(await cache.get('a')).toBe('a@0'); // 1 load, cached fresh
clock = 5; // still fresh
expect(await cache.get('a', { force: true })).toBe('a@5'); // force → reload despite fresh
expect(load).toHaveBeenCalledTimes(2);
// force still single-flights: two concurrent forced gets share one load.
const d = deferred<string>();
load.mockImplementationOnce(() => d.promise);
const p1 = cache.get('a', { force: true });
const p2 = cache.get('a', { force: true });
expect(load).toHaveBeenCalledTimes(3); // not 4
d.resolve('shared');
expect(await Promise.all([p1, p2])).toEqual(['shared', 'shared']);
});
it('shouldCache=false leaves the entry uncached (next call reloads) but still de-dupes in-flight', async () => {
let n = 0;
const load = vi.fn(async () => ({ error: true as boolean, n: ++n }));
const cache = createSingleFlightCache({
ttlMs: 10_000,
load,
shouldCache: (v) => !v.error,
});
// concurrent callers still share one load...
const [a, b] = await Promise.all([cache.get('k'), cache.get('k')]);
expect(a).toEqual({ error: true, n: 1 });
expect(b).toEqual({ error: true, n: 1 });
expect(load).toHaveBeenCalledTimes(1);
// ...but nothing was cached, so the next call reloads.
expect(await cache.get('k')).toEqual({ error: true, n: 2 });
expect(load).toHaveBeenCalledTimes(2);
// a good result IS cached.
load.mockImplementationOnce(async () => ({ error: false, n: ++n }));
const good = await cache.get('k');
expect(good).toEqual({ error: false, n: 3 });
expect(await cache.get('k')).toEqual({ error: false, n: 3 }); // served from cache
expect(load).toHaveBeenCalledTimes(3);
});
it('a rejected load propagates to all joined callers, is not cached, and clears in-flight', async () => {
let attempt = 0;
const load = vi.fn(async () => {
attempt++;
if (attempt === 1) {
throw new Error('boom');
}
return 'ok';
});
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
const p1 = cache.get('k');
const p2 = cache.get('k');
await expect(p1).rejects.toThrow('boom');
await expect(p2).rejects.toThrow('boom'); // same in-flight, same rejection
expect(load).toHaveBeenCalledTimes(1);
// in-flight cleared + not cached → next call retries and succeeds.
expect(await cache.get('k')).toBe('ok');
expect(load).toHaveBeenCalledTimes(2);
});
it('invalidate drops a cached entry; clear drops all', async () => {
const load = vi.fn(async (k: string) => k.toUpperCase());
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
await cache.get('a');
await cache.get('b');
expect(load).toHaveBeenCalledTimes(2);
cache.invalidate('a');
await cache.get('a'); // reloads
await cache.get('b'); // still cached
expect(load).toHaveBeenCalledTimes(3);
cache.clear();
await cache.get('a');
await cache.get('b');
expect(load).toHaveBeenCalledTimes(5);
});
it('allows retry when a loader throws before returning its promise', async () => {
const load = vi
.fn<() => Promise<string>>()
.mockImplementationOnce(() => {
throw new Error('failed before request');
})
.mockResolvedValue('recovered');
const cache = createSingleFlightCache({ ttlMs: 1000, load });
await expect(cache.get('key')).rejects.toThrow('failed before request');
await expect(cache.get('key')).resolves.toBe('recovered');
expect(load).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createLogger,
currentRequestId,
getLogLevel,
nextRequestId,
renderTraceBytes,
runWithoutRequestContext,
runWithRequestId,
runWithTraceRequest,
setLogLevel,
} from '../src/logger';
const originalLevel = getLogLevel();
afterEach(() => {
setLogLevel(originalLevel);
});
describe('TRACE diagnostics contract', () => {
it('renders every byte as continuous lowercase hexadecimal', () => {
expect(renderTraceBytes(new Uint8Array())).toBe('');
expect(renderTraceBytes(new Uint8Array([0x00, 0x01, 0x0f, 0x10, 0xff]))).toBe('00010f10ff');
});
it('renders only the addressed Uint8Array view', () => {
const backing = new Uint8Array([0xaa, 0x00, 0x0f, 0xff, 0xbb]);
expect(renderTraceBytes(backing.subarray(1, 4))).toBe('000fff');
expect(renderTraceBytes(Buffer.from(backing.buffer).subarray(2, 4))).toBe('0fff');
});
it('does not render bytes while TRACE is disabled', () => {
setLogLevel('info');
let rendered = false;
createLogger('Trace.Contract').trace(() => {
rendered = true;
return ['body=%s', renderTraceBytes(new Uint8Array([0xff]))];
});
expect(rendered).toBe(false);
});
it('creates distinct request contexts that survive asynchronous work', async () => {
setLogLevel('info');
const beforeDisabled = nextRequestId();
expect(runWithTraceRequest(() => currentRequestId())).toBeUndefined();
const afterDisabled = nextRequestId();
expect(afterDisabled).toBe(beforeDisabled + 1);
setLogLevel('trace');
const observeContext = () =>
runWithTraceRequest(async () => {
const before = currentRequestId();
await Promise.resolve();
const after = currentRequestId();
expect(after).toBe(before);
return after;
});
const first = await observeContext();
expect(currentRequestId()).toBeUndefined();
const second = await observeContext();
expect(currentRequestId()).toBeUndefined();
expect(first).toEqual(expect.any(Number));
expect(first).toBeGreaterThan(0);
expect(second).toEqual(expect.any(Number));
expect(second).toBeGreaterThan(0);
expect(second).not.toBe(first);
});
it('allocates once for nested TRACE contexts and reuses the request id', () => {
setLogLevel('trace');
const before = nextRequestId();
const observed = runWithTraceRequest(() => {
const outer = currentRequestId();
const inner = runWithTraceRequest(() => currentRequestId());
return { outer, inner };
});
const after = nextRequestId();
expect(observed.outer).toEqual(expect.any(Number));
expect(observed.inner).toBe(observed.outer);
expect(observed.outer).toBe(before + 1);
expect(after).toBe(before + 2);
expect(currentRequestId()).toBeUndefined();
});
it('detaches long-lived resources from the current request context', async () => {
await runWithRequestId(4243, async () => {
expect(currentRequestId()).toBe(4243);
const detached = await runWithoutRequestContext(async () => {
expect(currentRequestId()).toBeUndefined();
await Promise.resolve();
return currentRequestId();
});
expect(detached).toBeUndefined();
expect(currentRequestId()).toBe(4243);
});
expect(currentRequestId()).toBeUndefined();
});
it('reuses an existing request context even while TRACE is disabled', async () => {
setLogLevel('info');
await runWithRequestId(4242, async () => {
const observed = await runWithTraceRequest(async () => {
await Promise.resolve();
return currentRequestId();
});
expect(observed).toBe(4242);
});
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { isRealUin } from '../src/uin';
describe('isRealUin', () => {
it('accepts real QQ UINs (5..10 digits)', () => {
expect(isRealUin('10001')).toBe(true); // 5-digit floor
expect(isRealUin('123456789')).toBe(true);
expect(isRealUin('4294967295')).toBe(true); // uint32 max, 10 digits
});
it('rejects the empty string and "0"', () => {
expect(isRealUin('')).toBe(false);
expect(isRealUin('0')).toBe(false);
});
it('rejects too-short UINs (< 5 digits)', () => {
expect(isRealUin('1')).toBe(false);
expect(isRealUin('1234')).toBe(false);
});
it('rejects the garbage timestamp-shaped UINs from issue #162 (11+ digits)', () => {
expect(isRealUin('1701414379536')).toBe(false); // 13-digit ms timestamp
expect(isRealUin('1614019567632')).toBe(false);
expect(isRealUin('2014024115056')).toBe(false);
expect(isRealUin('12345678901')).toBe(false); // 11 digits — first rejected width
});
it('rejects non-digit strings (path traversal / injection guards)', () => {
expect(isRealUin('12345a')).toBe(false);
expect(isRealUin('12 345')).toBe(false);
expect(isRealUin('../etc')).toBe(false);
expect(isRealUin('123456\n')).toBe(false);
});
it('guarantees a BigInt-safe pure-digit string on accept', () => {
const uin = '123456789';
expect(isRealUin(uin)).toBe(true);
expect(() => BigInt(uin)).not.toThrow();
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { normalizeMirrorUrl, normalizeUpdateSourceConfig } from '../src/update-source';
import { normalizeRuntimeConfig } from '../src/runtime';
describe('update source configuration', () => {
it('normalizes proxy prefixes, deduplicates mirrors and retains order', () => {
expect(
normalizeUpdateSourceConfig({
mode: 'auto',
mirror: '',
mirrors: [
' https://EXAMPLE.com/proxy/ ',
'https://example.com/proxy',
'https://other.example',
],
}),
).toEqual({
mode: 'auto',
mirror: '',
mirrors: ['https://example.com/proxy', 'https://other.example'],
});
});
it.each([
'',
'file:///tmp/x',
'ftp://example.com',
'https://user:secret@example.com',
'https://example.com/?token=a',
'https://example.com/#a',
null,
])('rejects invalid mirror %s', (url) => {
expect(() => normalizeMirrorUrl(url)).toThrow();
});
it.each([
null,
[],
{ mode: ['auto'], mirrors: [] },
{ mode: 'mirror', mirror: '', mirrors: [] },
{ mode: 'auto', mirrors: Array(17).fill('https://example.com') },
])('rejects invalid configuration %j', (config) => {
expect(() => normalizeUpdateSourceConfig(config)).toThrow();
});
it('retains configured update sources when normalizing runtime settings', () => {
const updateSource = { mode: 'direct', mirror: '', mirrors: [] };
expect(normalizeRuntimeConfig({ updateSource, webuiPort: 7777 })).toMatchObject({
updateSource,
webuiPort: 7777,
});
expect(normalizeRuntimeConfig({})).not.toHaveProperty('updateSource');
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
defaultWebuiPresentation,
injectWebuiTarget,
parseWebuiPresentation,
resolveWebuiTarget,
} from '../src/webui-target';
describe('deployment presentation contract', () => {
it('requires explicit opt-in and rejects misspelled deployments', () => {
expect(resolveWebuiTarget(undefined)).toBeNull();
expect(resolveWebuiTarget(' ')).toBeNull();
expect(resolveWebuiTarget('maibot')).toBe('maibot');
expect(() => resolveWebuiTarget('mai-bot')).toThrow('SNOWLUMA_WEBUI_TARGET');
expect(() => resolveWebuiTarget('__proto__')).toThrow('SNOWLUMA_WEBUI_TARGET');
});
it('keeps the unconfigured document intact and injects before cached appearance', () => {
const html = '<html><head><script>readSavedAppearance()</script></head></html>';
expect(injectWebuiTarget(html, null)).toBe(html);
const injected = injectWebuiTarget(html, 'maibot');
expect(injected.indexOf('snowluma-webui-target')).toBeLessThan(
injected.indexOf('readSavedAppearance'),
);
expect(injected).toContain(JSON.stringify(defaultWebuiPresentation('maibot')));
expect(() => injectWebuiTarget('broken html', 'maibot')).toThrow('missing <head>');
});
it('validates the version, target, style and color mode without accepting arbitrary CSS', () => {
const valid = defaultWebuiPresentation('maibot');
expect(parseWebuiPresentation(valid)).toEqual(valid);
expect(
parseWebuiPresentation({ ...valid, mode: 'dark', css: 'body{display:none}' }),
).toEqual({ ...valid, mode: 'dark' });
expect(parseWebuiPresentation({ version: 1, target: null })).toEqual({
version: 1,
target: null,
});
for (const input of [
null,
{},
{ ...valid, version: 2 },
{ ...valid, target: 'other' },
{ ...valid, style: 'modern' },
{ ...valid, mode: 'blue' },
]) {
expect(() => parseWebuiPresentation(input)).toThrow();
}
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
+10
View File
@@ -0,0 +1,10 @@
// Vitest config for @snowluma/common. Pure-TS package (no proton), so a
// plain node-environment runner over tests/ is enough.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});