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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}`;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import manifest from '../../../package.json' with { type: 'json' };
|
||||
|
||||
export const APP_VERSION = manifest.version;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user