feat: add app configuration management with Kysely integration

- Implemented `getAppConfig` and `upsertAppConfig` functions in `config.ts` for managing application settings in the database.
- Updated `mod.ts` to export the new configuration functions.
- Refactored `ComicConfig.ts` to load and update comic watch paths using the new configuration functions.
- Modified `comic_watcher.ts` to accept paths as parameters for creating watchers.
- Created a new settings router in `settings.ts` for managing application settings via HTTP requests.
- Integrated the settings router into the main server in `server.ts`.
- Updated the settings management to use the new database-backed configuration.
- Removed legacy configuration management code from `configRW.ts`.
- Added integration tests for the settings router and error handling.
- Updated `vitest` configuration for testing.
- Cleaned up unused type definitions in `pnpm-lock.yaml`.
This commit is contained in:
monoid 2025-09-30 23:15:20 +09:00
parent 018e2e998b
commit d28c255d21
22 changed files with 1109 additions and 259 deletions

View file

@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/Spinner";
import type { ServerSettingResponse, ServerSettingUpdate } from "dbtype/mod.ts";
import { ApiError as FetchApiError } from "@/hook/fetcher";
const createSnapshot = (setting: ServerSettingResponse["persisted"]) => ({
secure: setting.secure,
cli: setting.cli,
forbid_remote_admin_login: setting.forbid_remote_admin_login,
guest: [...setting.guest],
});
type FormState = ReturnType<typeof createSnapshot>;
type FeedbackState = { type: "success" | "error"; message: string } | null;
type ServerSettingCardProps = {
isAdmin: boolean;
loading: boolean;
error: unknown;
setting?: ServerSettingResponse;
onSave: (payload: ServerSettingUpdate) => Promise<ServerSettingResponse>;
};
const defaultState: FormState = {
secure: true,
cli: false,
forbid_remote_admin_login: true,
guest: [],
};
const areArraysEqual = (a: string[], b: string[]) => {
if (a.length !== b.length) {
return false;
}
return a.every((value, index) => value === b[index]);
};
export function ServerSettingCard({ isAdmin, loading, error, setting, onSave }: ServerSettingCardProps) {
const [formState, setFormState] = useState<FormState>(() => (setting ? createSnapshot(setting.persisted) : defaultState));
const [saving, setSaving] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>(null);
useEffect(() => {
if (setting) {
setFormState(createSnapshot(setting.persisted));
setFeedback(null);
}
}, [setting]);
const permissionOptions = useMemo(() => setting?.permissions ?? [], [setting]);
const baselinePersisted = useMemo(() => setting ? createSnapshot(setting.persisted) : defaultState, [setting]);
const sortedGuest = useMemo(() => [...formState.guest].sort(), [formState.guest]);
const baselineGuest = useMemo(() => [...baselinePersisted.guest].sort(), [baselinePersisted.guest]);
const guestChanged = !areArraysEqual(sortedGuest, baselineGuest);
const hasChanges = setting
? formState.secure !== baselinePersisted.secure
|| formState.cli !== baselinePersisted.cli
|| formState.forbid_remote_admin_login !== baselinePersisted.forbid_remote_admin_login
|| guestChanged
: false;
const errorMessage = useMemo(() => {
if (!error) {
return null;
}
if (error instanceof FetchApiError) {
return `(${error.status}) ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}, [error]);
const toggleGuestPermission = (permission: string) => {
setFormState((prev) => ({
...prev,
guest: prev.guest.includes(permission)
? prev.guest.filter((value) => value !== permission)
: [...prev.guest, permission],
}));
setFeedback(null);
};
const onChangeBoolean = (key: keyof FormState) => (value: boolean) => {
setFormState((prev) => ({
...prev,
[key]: value,
}));
setFeedback(null);
};
const handleSave = async () => {
if (!setting) {
return;
}
const payload: ServerSettingUpdate = {};
if (formState.secure !== baselinePersisted.secure) {
payload.secure = formState.secure;
}
if (formState.cli !== baselinePersisted.cli) {
payload.cli = formState.cli;
}
if (formState.forbid_remote_admin_login !== baselinePersisted.forbid_remote_admin_login) {
payload.forbid_remote_admin_login = formState.forbid_remote_admin_login;
}
if (guestChanged) {
payload.guest = sortedGuest;
}
if (Object.keys(payload).length === 0) {
setFeedback({ type: "success", message: "변경 사항이 없습니다." });
return;
}
try {
setSaving(true);
const updated = await onSave(payload);
setFormState(createSnapshot(updated.persisted));
setFeedback({ type: "success", message: "서버 설정을 저장했습니다." });
} catch (err) {
const message = err instanceof FetchApiError
? `(${err.status}) ${err.message}`
: err instanceof Error
? err.message
: "설정 저장 중 오류가 발생했습니다.";
setFeedback({ type: "error", message });
} finally {
setSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Server Settings</CardTitle>
</CardHeader>
<CardContent>
{!isAdmin ? (
<p className="text-sm text-muted-foreground">
.
</p>
) : (
<div className="space-y-4">
{errorMessage && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{errorMessage}
</div>
)}
{loading && !setting ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="text-lg" />
<span> </span>
</div>
) : null}
{setting && (
<>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border p-3">
<p className="text-xs uppercase text-muted-foreground">Hostname</p>
<p className="text-sm font-medium">{setting.env.hostname}</p>
</div>
<div className="rounded-md border p-3">
<p className="text-xs uppercase text-muted-foreground">Port</p>
<p className="text-sm font-medium">{setting.env.port}</p>
</div>
<div className="rounded-md border p-3">
<p className="text-xs uppercase text-muted-foreground">Mode</p>
<p className="text-sm font-medium capitalize">{setting.env.mode}</p>
</div>
</div>
<div className="space-y-3">
<label className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="font-medium">Secure cookies</p>
<p className="text-sm text-muted-foreground">HTTPS .</p>
</div>
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={formState.secure}
onChange={(event) => onChangeBoolean("secure")(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="font-medium">CLI bootstrap</p>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={formState.cli}
onChange={(event) => onChangeBoolean("cli")(event.target.checked)}
/>
</label>
<label className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={formState.forbid_remote_admin_login}
onChange={(event) => onChangeBoolean("forbid_remote_admin_login")(event.target.checked)}
/>
</label>
</div>
<div className="space-y-2">
<div>
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<div className="flex flex-wrap gap-2">
{permissionOptions.map((permission) => {
const active = formState.guest.includes(permission);
return (
<Button
key={permission}
type="button"
size="sm"
variant={active ? "default" : "outline"}
onClick={() => toggleGuestPermission(permission)}
>
{permission}
</Button>
);
})}
{permissionOptions.length === 0 && (
<span className="text-sm text-muted-foreground"> .</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{formState.guest.length === 0 ? (
<span className="text-xs text-muted-foreground"> .</span>
) : (
formState.guest.map((permission) => (
<Badge key={`guest-${permission}`} variant="secondary">
{permission}
</Badge>
))
)}
</div>
</div>
{feedback && (
<div
className={`text-sm ${feedback.type === "error" ? "text-destructive" : "text-emerald-600"}`}
>
{feedback.message}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? "저장 중…" : "변경 사항 저장"}
</Button>
</div>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default ServerSettingCard;

View file

@ -0,0 +1,10 @@
import useSWR from "swr";
import { fetcher } from "./fetcher";
import type { ServerSettingResponse } from "dbtype/mod.ts";
export function useServerSettings(enabled: boolean) {
return useSWR<ServerSettingResponse>(
enabled ? "/api/settings" : null,
(url: string) => fetcher(url, { credentials: "include" }),
);
}

View file

@ -1,8 +1,14 @@
import { useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import BuildInfoCard from "@/components/BuildInfoCard";
import { useServerSettings } from "@/hook/useServerSettings";
import { useLogin } from "@/state/user";
import { fetcher } from "@/hook/fetcher";
import type { ServerSettingResponse, ServerSettingUpdate } from "dbtype/mod.ts";
import { ServerSettingCard } from "@/components/ServerSettingCard";
function LightModeView() {
return <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
@ -45,9 +51,25 @@ function DarkModeView() {
export function SettingPage() {
const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode();
const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const login = useLogin();
const isAdmin = login?.username === "admin";
const { data: serverSetting, error: serverError, isLoading: serverLoading, mutate } = useServerSettings(isAdmin);
const handleSave = useCallback(async (payload: ServerSettingUpdate) => {
const response = await fetcher("/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
});
const updated = response as ServerSettingResponse;
await mutate(updated, false);
return updated;
}, [mutate]);
return (
<div className="p-4 space-y-2">
<div className="p-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Settings</CardTitle>
@ -86,6 +108,13 @@ export function SettingPage() {
</div>
</CardContent>
</Card>
<ServerSettingCard
isAdmin={isAdmin}
loading={serverLoading}
error={serverError}
setting={serverSetting}
onSave={handleSave}
/>
<BuildInfoCard />
</div>
)

View file

@ -97,4 +97,29 @@ export const LoginResetRequestSchema = z.object({
newpassword: z.string(),
});
export type LoginResetRequest = z.infer<typeof LoginResetRequestSchema>;
export type LoginResetRequest = z.infer<typeof LoginResetRequestSchema>;
export const ServerPersistedSettingSchema = z.object({
secure: z.boolean(),
cli: z.boolean(),
forbid_remote_admin_login: z.boolean(),
guest: z.array(z.string()),
});
export type ServerPersistedSetting = z.infer<typeof ServerPersistedSettingSchema>;
export const ServerSettingResponseSchema = z.object({
env: z.object({
hostname: z.string(),
port: z.number(),
mode: z.enum(["development", "production"]),
}),
persisted: ServerPersistedSettingSchema,
permissions: z.array(z.string()),
});
export type ServerSettingResponse = z.infer<typeof ServerSettingResponseSchema>;
export const ServerSettingUpdateSchema = ServerPersistedSettingSchema.partial();
export type ServerSettingUpdate = z.infer<typeof ServerSettingUpdateSchema>;

View file

@ -50,6 +50,11 @@ export interface UserSettings {
settings: string | null;
}
export interface AppConfig {
key: string;
value: string;
}
export interface DB {
doc_tag_relation: DocTagRelation;
document: Document;
@ -58,4 +63,5 @@ export interface DB {
tags: Tags;
users: Users;
user_settings: UserSettings;
app_config: AppConfig;
}

View file

@ -0,0 +1,22 @@
import { Kysely } from "kysely";
const CONFIG_TABLE = "app_config";
const SCHEMA_VERSION = "2025-09-30";
export async function up(db: Kysely<any>) {
await db.schema
.createTable(CONFIG_TABLE)
.ifNotExists()
.addColumn("key", "varchar", (col) => col.notNull().primaryKey())
.addColumn("value", "text", (col) => col.notNull())
.execute();
await db
.updateTable("schema_migration")
.set({ version: SCHEMA_VERSION, dirty: 0 })
.execute();
}
export async function down(_db: Kysely<any>) {
throw new Error("Downward migrations are not supported. Restore from backup.");
}

View file

@ -7,7 +7,9 @@
"scripts": {
"dev": "tsx watch src/app.ts",
"start": "tsx src/app.ts",
"migrate": "tsx tools/migration.ts"
"migrate": "tsx tools/migration.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"author": "",
"license": "ISC",
@ -29,10 +31,10 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/koa-compose": "^3.2.8",
"@types/node": "^22.15.33",
"@types/tiny-async-pool": "^1.0.5",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^2.1.3"
}
}

View file

@ -1,80 +1,160 @@
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import type { Permission } from "./permission/permission.ts";
import { existsSync, readFileSync } from "node:fs";
import type { Kysely } from "kysely";
import type { db } from "dbtype";
import { Permission } from "./permission/permission.ts";
import { getAppConfig, upsertAppConfig } from "./db/config.ts";
export interface SettingConfig {
/**
* if true, server will bind on '127.0.0.1' rather than '0.0.0.0'
*/
localmode: boolean;
/**
* secure only
*/
secure: boolean;
/**
* guest permission
*/
guest: Permission[];
/**
* JWT secret key. if you change its value, all access tokens are invalidated.
*/
jwt_secretkey: string;
/**
* the port which running server is binding on.
*/
hostname: string;
port: number;
mode: "development" | "production";
/**
* if true, do not show 'electron' window and show terminal only.
*/
secure: boolean;
guest: Permission[];
jwt_secretkey: string;
cli: boolean;
/** forbid to login admin from remote client. but, it do not invalidate access token.
* if you want to invalidate access token, change 'jwt_secretkey'. */
forbid_remote_admin_login: boolean;
}
const default_setting: SettingConfig = {
localmode: true,
export type PersistedSetting = Pick<SettingConfig, "secure" | "guest" | "cli" | "forbid_remote_admin_login">;
type EnvSetting = Pick<SettingConfig, "hostname" | "port" | "mode" | "jwt_secretkey">;
const CONFIG_KEY = "server.settings";
const LEGACY_SETTINGS_PATH = "settings.json";
const persistedDefault: PersistedSetting = {
secure: true,
guest: [],
jwt_secretkey: "itsRandom",
port: 8080,
mode: "production",
cli: false,
forbid_remote_admin_login: true,
};
let setting: null | SettingConfig = null;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const setEmptyToDefault = (target: any, default_table: SettingConfig) => {
let diff_occur = false;
for (const key in default_table) {
if (key === undefined || key in target) {
continue;
}
target[key] = default_table[key as keyof SettingConfig];
diff_occur = true;
let cachedSetting: SettingConfig | null = null;
export const initializeSetting = async (db: Kysely<db.DB>): Promise<SettingConfig> => {
if (cachedSetting) {
return cachedSetting;
}
return diff_occur;
const persisted = await loadPersistedSetting(db);
const envSetting = loadEnvSetting();
cachedSetting = {
...persisted,
...envSetting,
};
return cachedSetting;
};
export const read_setting_from_file = () => {
const ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json", { encoding: "utf8" })) : {};
const partial_occur = setEmptyToDefault(ret, default_setting);
if (partial_occur) {
writeFileSync("settings.json", JSON.stringify(ret));
}
return ret as SettingConfig;
export const refreshSetting = async (db: Kysely<db.DB>): Promise<SettingConfig> => {
cachedSetting = null;
return initializeSetting(db);
};
export function get_setting(): SettingConfig {
if (setting === null) {
setting = read_setting_from_file();
const env = process.env.NODE_ENV;
if (env !== undefined && env !== "production" && env !== "development") {
throw new Error('process unknown value in NODE_ENV: must be either "development" or "production"');
}
setting.mode = env ?? setting.mode;
export type PersistedSettingUpdate = Partial<PersistedSetting>;
export const updatePersistedSetting = async (
db: Kysely<db.DB>,
patch: PersistedSettingUpdate,
): Promise<SettingConfig> => {
const current = await initializeSetting(db);
const basePersisted: PersistedSetting = {
secure: current.secure,
guest: current.guest,
cli: current.cli,
forbid_remote_admin_login: current.forbid_remote_admin_login,
};
const nextPersisted = mergePersisted({ ...basePersisted, ...patch });
await upsertAppConfig(db, CONFIG_KEY, nextPersisted);
cachedSetting = {
...current,
...nextPersisted,
};
return cachedSetting;
};
export const get_setting = (): SettingConfig => {
if (!cachedSetting) {
throw new Error("Settings have not been initialized. Call initializeSetting first.");
}
return setting;
}
return cachedSetting;
};
const loadEnvSetting = (): EnvSetting => {
const host = process.env.SERVER_HOST ?? process.env.HOST;
if (!host) {
throw new Error("SERVER_HOST environment variable is required");
}
const portString = process.env.SERVER_PORT;
if (!portString) {
throw new Error("SERVER_PORT environment variable is required");
}
const port = Number.parseInt(portString, 10);
if (!Number.isFinite(port)) {
throw new Error("SERVER_PORT must be a valid integer");
}
const modeValue = process.env.SERVER_MODE ?? process.env.NODE_ENV;
if (!modeValue) {
throw new Error("SERVER_MODE or NODE_ENV environment variable is required");
}
if (modeValue !== "development" && modeValue !== "production") {
throw new Error('SERVER_MODE / NODE_ENV must be either "development" or "production"');
}
const jwtSecret = process.env.JWT_SECRET_KEY ?? process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error("JWT_SECRET_KEY environment variable is required");
}
return {
hostname: host,
port,
mode: modeValue,
jwt_secretkey: jwtSecret,
};
};
const loadPersistedSetting = async (db: Kysely<db.DB>): Promise<PersistedSetting> => {
const stored = await getAppConfig<Partial<PersistedSetting>>(db, CONFIG_KEY);
if (stored) {
return mergePersisted(stored);
}
const legacy = readLegacySettings();
const mergedLegacy = mergePersisted(legacy ?? {});
await upsertAppConfig(db, CONFIG_KEY, mergedLegacy);
return mergedLegacy;
};
const mergePersisted = (input: Partial<PersistedSetting>): PersistedSetting => {
const validPermissions = new Set<Permission>(Object.values(Permission));
const guest = Array.isArray(input.guest)
? Array.from(
new Set(
input.guest.filter((value): value is Permission => validPermissions.has(value as Permission)),
),
)
: persistedDefault.guest;
return {
secure: input.secure ?? persistedDefault.secure,
guest,
cli: input.cli ?? persistedDefault.cli,
forbid_remote_admin_login: input.forbid_remote_admin_login ?? persistedDefault.forbid_remote_admin_login,
};
};
const readLegacySettings = (): Partial<PersistedSetting> | undefined => {
if (!existsSync(LEGACY_SETTINGS_PATH)) {
return undefined;
}
try {
return JSON.parse(readFileSync(LEGACY_SETTINGS_PATH, { encoding: "utf8" })) as Partial<PersistedSetting>;
} catch (error) {
console.error("[config] Failed to parse settings.json", error);
return undefined;
}
};

View file

@ -0,0 +1,35 @@
import type { Kysely } from "kysely";
import type { db } from "dbtype";
type DB = db.DB;
const TABLE = "app_config";
export const getAppConfig = async <T>(db: Kysely<DB>, key: string): Promise<T | undefined> => {
const row = await db
.selectFrom(TABLE)
.select("value")
.where("key", "=", key)
.executeTakeFirst();
if (!row) {
return undefined;
}
try {
return JSON.parse(row.value) as T;
} catch (error) {
console.error(`[config] Failed to parse value for key ${key}:`, error);
return undefined;
}
};
export const upsertAppConfig = async <T>(db: Kysely<DB>, key: string, value: T): Promise<void> => {
const payload = JSON.stringify(value);
await db
.insertInto(TABLE)
.values({ key, value: payload })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: payload }))
.execute();
};

View file

@ -1,3 +1,4 @@
export * from "./doc.ts";
export * from "./tag.ts";
export * from "./user.ts";
export * from "./config.ts";

View file

@ -1,7 +1,58 @@
import { ConfigManager } from "../../util/configRW.ts";
import ComicSchema from "./ComicConfig.schema.json" with { type: "json" };
import { existsSync, readFileSync } from "node:fs";
import type { Kysely } from "kysely";
import type { db } from "dbtype";
import { getAppConfig, upsertAppConfig } from "../../db/config.ts";
export interface ComicConfig {
watch: string[];
}
export const ComicConfig = new ConfigManager<ComicConfig>("comic_config.json", { watch: [] }, ComicSchema);
const CONFIG_KEY = "diff.comic.watch";
const LEGACY_PATH = "comic_config.json";
let cache: ComicConfig | null = null;
const normalize = (input: Partial<ComicConfig> | undefined): ComicConfig => {
const watch = Array.isArray(input?.watch)
? input!.watch.filter((item): item is string => typeof item === "string" && item.length > 0)
: [];
return { watch };
};
const readLegacyConfig = (): Partial<ComicConfig> | undefined => {
if (!existsSync(LEGACY_PATH)) {
return undefined;
}
try {
return JSON.parse(readFileSync(LEGACY_PATH, { encoding: "utf8" }));
} catch (error) {
console.error("[config] Failed to parse comic_config.json", error);
return undefined;
}
};
export const loadComicConfig = async (db: Kysely<db.DB>): Promise<ComicConfig> => {
if (cache) {
return cache;
}
const stored = await getAppConfig<ComicConfig>(db, CONFIG_KEY);
if (stored) {
cache = normalize(stored);
return cache;
}
const legacy = normalize(readLegacyConfig());
await upsertAppConfig(db, CONFIG_KEY, legacy);
cache = legacy;
return cache;
};
export const updateComicConfig = async (db: Kysely<db.DB>, config: ComicConfig): Promise<void> => {
cache = normalize(config);
await upsertAppConfig(db, CONFIG_KEY, cache);
};
export const clearComicConfigCache = () => {
cache = null;
};

View file

@ -1,4 +1,3 @@
import { ComicConfig } from "./ComicConfig.ts";
import { WatcherCompositer } from "./compositer.ts";
import { RecursiveWatcher } from "./recursive_watcher.ts";
import { WatcherFilter } from "./watcher_filter.ts";
@ -6,8 +5,11 @@ import { WatcherFilter } from "./watcher_filter.ts";
const createComicWatcherBase = (path: string) => {
return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
};
export const createComicWatcher = () => {
const file = ComicConfig.get_config_file();
console.log(`register comic ${file.watch.join(",")}`);
return new WatcherCompositer(file.watch.map((path) => createComicWatcherBase(path)));
export const createComicWatcher = (paths: string[]) => {
const uniquePaths = [...new Set(paths)].filter((path) => path.length > 0);
if (uniquePaths.length === 0) {
console.warn("[diff] No comic watch paths configured");
}
return new WatcherCompositer(uniquePaths.map((path) => createComicWatcherBase(path)));
};

View file

@ -0,0 +1,71 @@
import { Elysia, t, type Static } from "elysia";
import type { Kysely } from "kysely";
import type { db } from "dbtype";
import { AdminOnly, Permission } from "../permission/permission.ts";
import { get_setting, updatePersistedSetting, type PersistedSettingUpdate } from "../SettingConfig.ts";
const permissionOptions = Object.values(Permission).sort() as string[];
const updateBodySchema = t.Object({
secure: t.Optional(t.Boolean()),
cli: t.Optional(t.Boolean()),
forbid_remote_admin_login: t.Optional(t.Boolean()),
guest: t.Optional(t.Array(t.Enum(Permission))),
});
type UpdateBody = Static<typeof updateBodySchema>;
type SettingResponse = {
env: {
hostname: string;
port: number;
mode: "development" | "production";
};
persisted: {
secure: boolean;
cli: boolean;
forbid_remote_admin_login: boolean;
guest: string[];
};
permissions: string[];
};
const buildResponse = (): SettingResponse => {
const setting = get_setting();
return {
env: {
hostname: setting.hostname,
port: setting.port,
mode: setting.mode,
},
persisted: {
secure: setting.secure,
cli: setting.cli,
forbid_remote_admin_login: setting.forbid_remote_admin_login,
guest: [...setting.guest],
},
permissions: [...permissionOptions],
};
};
export const createSettingsRouter = (db: Kysely<db.DB>) =>
new Elysia({ name: "settings-router" })
.get("/settings", () => buildResponse(), {
beforeHandle: AdminOnly,
})
.patch("/settings", async ({ body }) => {
const payload = body as UpdateBody;
const update: PersistedSettingUpdate = {
secure: payload.secure,
cli: payload.cli,
forbid_remote_admin_login: payload.forbid_remote_admin_login,
guest: payload.guest,
};
await updatePersistedSetting(db, update);
return buildResponse();
}, {
beforeHandle: AdminOnly,
body: updateBodySchema,
});
export default createSettingsRouter;

View file

@ -5,16 +5,18 @@ import { html } from "@elysiajs/html";
import { connectDB } from "./database.ts";
import { createDiffRouter, DiffManager } from "./diff/mod.ts";
import { get_setting } from "./SettingConfig.ts";
import { get_setting, initializeSetting } from "./SettingConfig.ts";
import { readFileSync } from "node:fs";
import { createSqliteDocumentAccessor, createSqliteTagController, createSqliteUserController } from "./db/mod.ts";
import { createLoginRouter, createUserHandler, getAdmin, isAdminFirst } from "./login.ts";
import getContentRouter from "./route/contents.ts";
import { error_handler } from "./route/error_handler.ts";
import { createSettingsRouter } from "./route/settings.ts";
import { createInterface as createReadlineInterface } from "node:readline";
import { createComicWatcher } from "./diff/watcher/comic_watcher.ts";
import { loadComicConfig } from "./diff/watcher/ComicConfig.ts";
import type { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod.ts";
import { getTagRounter } from "./route/tags.ts";
@ -58,14 +60,16 @@ const normalizeError = (error: unknown): Error => {
export async function create_server() {
const setting = get_setting();
const db = await connectDB();
await initializeSetting(db);
const setting = get_setting();
const userController = createSqliteUserController(db);
const documentController = createSqliteDocumentAccessor(db);
const tagController = createSqliteTagController(db);
const diffManger = new DiffManager(documentController);
diffManger.register("comic", createComicWatcher());
const comicConfig = await loadComicConfig(db);
await diffManger.register("comic", createComicWatcher(comicConfig.watch));
if (setting.cli) {
const userAdmin = await getAdmin(userController);
@ -107,6 +111,7 @@ export async function create_server() {
.use(createDiffRouter(diffManger))
.use(getContentRouter(documentController))
.use(getTagRounter(tagController))
.use(createSettingsRouter(db))
.use(createLoginRouter(userController))
)
.get("/doc/:id", async ({ params: { id }, set }) => {
@ -137,7 +142,7 @@ export async function create_server() {
.get("/setting", () => index_html)
.get("/tags", () => index_html)
.listen({
hostname: setting.localmode ? "127.0.0.1" : "0.0.0.0",
hostname: setting.hostname,
port: setting.port,
});

View file

@ -1,11 +1,7 @@
import { Elysia } from "elysia";
import { get_setting } from "./SettingConfig.ts";
import { connectDB } from "./database.ts";
import type { Kysely } from "kysely";
import type { DB } from "dbtype/src/types.ts";
export const SettingPlugin = new Elysia({
name: "setting",
seed: "ServerConfig"
})
.state("setting", get_setting());
name: "setting",
seed: "ServerConfig",
}).derive(() => ({ setting: get_setting() }));

View file

@ -1,46 +1 @@
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
export class ConfigManager<T extends object> {
path: string;
default_config: T;
config: T | null;
schema: object;
constructor(path: string, default_config: T, schema: object) {
this.path = path;
this.default_config = default_config;
this.config = null;
this.schema = schema;
}
get_config_file(): T {
if (this.config !== null) return this.config;
this.config = { ...this.read_config_file() };
return this.config;
}
private emptyToDefault(target: T) {
let occur = false;
for (const key in this.default_config) {
if (key === undefined || key in target) {
continue;
}
target[key] = this.default_config[key];
occur = true;
}
return occur;
}
read_config_file(): T {
if (!existsSync(this.path)) {
writeFileSync(this.path, JSON.stringify(this.default_config));
return this.default_config;
}
const ret = JSON.parse(readFileSync(this.path, { encoding: "utf8" }));
if (this.emptyToDefault(ret)) {
writeFileSync(this.path, JSON.stringify(ret));
}
return ret;
}
async write_config_file(new_config: T) {
this.config = new_config;
await fs.writeFile(`${this.path}.temp`, JSON.stringify(new_config));
await fs.rename(`${this.path}.temp`, this.path);
}
}
export {};

View file

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
import { Elysia } from "elysia";
import { createDiffRouter } from "../src/diff/router.ts";
import type { DiffManager } from "../src/diff/diff.ts";
const adminUser = { username: "admin", permission: [] as string[] };
const createTestApp = (diffManager: DiffManager) => {
const authPlugin = new Elysia({ name: "test-auth" })
.state("user", adminUser)
.derive(() => ({ user: adminUser }));
return new Elysia({ name: "diff-test" })
.use(authPlugin)
.use(createDiffRouter(diffManager));
};
describe("Diff router integration", () => {
let app: ReturnType<typeof createTestApp>
let diffManager: DiffManager;
let getAddedMock: ReturnType<typeof vi.fn>;
let commitMock: ReturnType<typeof vi.fn>;
let commitAllMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
getAddedMock = vi.fn(() => [
{
type: "comic",
value: [
{ path: "alpha.zip", type: "archive" },
],
},
]);
commitMock = vi.fn(async () => 101);
commitAllMock = vi.fn(async () => [201, 202]);
diffManager = {
getAdded: getAddedMock,
commit: commitMock,
commitAll: commitAllMock,
} as unknown as DiffManager;
app = createTestApp(diffManager);
});
afterEach(async () => {
if (app?.server) {
await app.stop();
}
vi.clearAllMocks();
});
it("GET /diff/list returns grouped pending items", async () => {
const response = await app.handle(new Request("http://localhost/diff/list"));
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload).toEqual([
{
type: "comic",
value: [
{ path: "alpha.zip", type: "archive" },
],
},
]);
expect(getAddedMock).toHaveBeenCalledTimes(1);
});
it("POST /diff/commit commits each queued item", async () => {
const request = new Request("http://localhost/diff/commit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify([
{ type: "comic", path: "alpha.zip" },
]),
});
commitMock.mockResolvedValueOnce(555);
const response = await app.handle(request);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload).toEqual({ ok: true, docs: [555] });
expect(commitMock).toHaveBeenCalledWith("comic", "alpha.zip");
});
it("POST /diff/commitall flushes all entries for the type", async () => {
const request = new Request("http://localhost/diff/commitall", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "comic" }),
});
const response = await app.handle(request);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload).toEqual({ ok: true });
expect(commitAllMock).toHaveBeenCalledWith("comic");
});
});

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { ClientRequestError, error_handler } from "../src/route/error_handler.ts";
import { DocumentBodySchema } from "dbtype";
const createSet = () => ({ status: undefined as number | string | undefined });
describe("error_handler", () => {
it("formats ClientRequestError with provided status", () => {
const set = createSet();
const result = error_handler({
code: "UNKNOWN",
error: new ClientRequestError(400, "invalid payload"),
set,
});
expect(set.status).toBe(400);
expect(result).toEqual({
code: 400,
message: "BadRequest",
detail: "invalid payload",
});
});
it("coerces ZodError into a 400 response", () => {
const parseResult = DocumentBodySchema.safeParse({});
const set = createSet();
if (parseResult.success) {
throw new Error("Expected validation error");
}
const result = error_handler({
code: "VALIDATION",
error: parseResult.error,
set,
});
expect(set.status).toBe(400);
expect(result.code).toBe(400);
expect(result.message).toBe("BadRequest");
expect(result.detail).toContain("Required");
});
it("defaults to 500 for unexpected errors", () => {
const set = createSet();
const result = error_handler({
code: "INTERNAL_SERVER_ERROR",
error: new Error("boom"),
set,
});
expect(set.status).toBe(500);
expect(result).toEqual({
code: 500,
message: "Internal Server Error",
detail: "boom",
});
});
});

View file

@ -0,0 +1,156 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { Elysia } from "elysia";
import { Kysely, SqliteDialect } from "kysely";
import SqliteDatabase from "better-sqlite3";
import type { db } from "dbtype";
import { createSettingsRouter } from "../src/route/settings.ts";
import { error_handler } from "../src/route/error_handler.ts";
import { get_setting, refreshSetting } from "../src/SettingConfig.ts";
import { Permission } from "../src/permission/permission.ts";
const normalizeError = (error: unknown): Error => {
if (error instanceof Error) {
return error;
}
if (typeof error === "string") {
return new Error(error);
}
try {
return new Error(JSON.stringify(error));
} catch (_err) {
return new Error("Unknown error");
}
};
describe("settings router", () => {
let sqlite: InstanceType<typeof SqliteDatabase>;
let database: Kysely<db.DB>;
beforeAll(async () => {
process.env.SERVER_HOST = "127.0.0.1";
process.env.SERVER_PORT = "3000";
process.env.SERVER_MODE = "development";
process.env.JWT_SECRET_KEY = "test-secret";
sqlite = new SqliteDatabase(":memory:");
const dialect = new SqliteDialect({ database: sqlite });
database = new Kysely<db.DB>({ dialect });
await database.schema
.createTable("app_config")
.addColumn("key", "text", (col) => col.primaryKey())
.addColumn("value", "text")
.execute();
await refreshSetting(database);
});
afterAll(async () => {
await database.destroy();
sqlite.close();
});
beforeEach(async () => {
await database.deleteFrom("app_config").execute();
await refreshSetting(database);
});
const createTestApp = (username: string) => {
const user = { username, permission: [] as string[] };
return new Elysia({ name: `settings-test-${username}` })
.state("user", user)
.derive(() => ({ user }))
.onError((context) =>
error_handler({
code: typeof context.code === "number" ? String(context.code) : context.code,
error: normalizeError(context.error),
set: context.set,
}),
)
.use(createSettingsRouter(database));
};
it("rejects access for non-admin users", async () => {
const app = createTestApp("guest");
try {
const response = await app.handle(new Request("http://localhost/settings"));
expect(response.status).toBe(403);
} finally {
if (app.server) {
await app.stop();
}
}
});
it("returns current configuration for admin", async () => {
const app = createTestApp("admin");
try {
const response = await app.handle(new Request("http://localhost/settings"));
expect(response.status).toBe(200);
const payload = await response.json();
const expected = get_setting();
expect(payload).toMatchObject({
persisted: {
secure: expected.secure,
cli: expected.cli,
forbid_remote_admin_login: expected.forbid_remote_admin_login,
guest: expected.guest,
},
env: {
hostname: expected.hostname,
port: expected.port,
mode: expected.mode,
},
});
expect(Array.isArray(payload.permissions)).toBe(true);
expect(new Set(payload.permissions)).toEqual(new Set(Object.values(Permission)));
} finally {
if (app.server) {
await app.stop();
}
}
});
it("updates persisted settings and returns the new state", async () => {
const app = createTestApp("admin");
try {
const request = new Request("http://localhost/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
secure: false,
cli: true,
guest: ["QueryContent"],
forbid_remote_admin_login: false,
}),
});
const response = await app.handle(request);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.persisted).toEqual({
secure: false,
cli: true,
forbid_remote_admin_login: false,
guest: ["QueryContent"],
});
// A follow-up GET should reflect the updated values
const followUp = await app.handle(new Request("http://localhost/settings"));
expect(followUp.status).toBe(200);
const followUpPayload = await followUp.json();
expect(followUpPayload.persisted).toEqual({
secure: false,
cli: true,
forbid_remote_admin_login: false,
guest: ["QueryContent"],
});
} finally {
if (app.server) {
await app.stop();
}
}
});
});

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"types": ["vitest/globals"],
"outDir": "./dist-vitest"
},
"include": ["src", "tests"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["tests/**/*.test.ts"],
},
});

200
pnpm-lock.yaml generated
View file

@ -215,9 +215,6 @@ importers:
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/koa-compose':
specifier: ^3.2.8
version: 3.2.8
'@types/node':
specifier: ^22.15.33
version: 22.15.33
@ -230,6 +227,9 @@ importers:
typescript:
specifier: ^5.8.3
version: 5.8.3
vitest:
specifier: ^2.1.3
version: 2.1.9(@types/node@22.15.33)
packages:
@ -1426,27 +1426,12 @@ packages:
'@ts-morph/common@0.19.0':
resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==}
'@types/accepts@1.3.7':
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/better-sqlite3@7.6.9':
resolution: {integrity: sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/content-disposition@0.5.9':
resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
'@types/cookies@0.9.1':
resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==}
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@ -1480,30 +1465,6 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/express-serve-static-core@5.0.6':
resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==}
'@types/express@5.0.3':
resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
'@types/http-assert@1.5.6':
resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/keygrip@1.0.6':
resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
'@types/koa-compose@3.2.8':
resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==}
'@types/koa@2.15.0':
resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/node@22.15.30':
resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==}
@ -1516,12 +1477,6 @@ packages:
'@types/prop-types@15.7.14':
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@ -1530,12 +1485,6 @@ packages:
'@types/react@18.3.23':
resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
'@types/send@0.17.5':
resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
'@types/serve-static@1.15.8':
resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
'@types/tiny-async-pool@1.0.5':
resolution: {integrity: sha512-8hqr+s4rBthBtb+k02NXejl7BGVbj7CD/ZB2rMSvoSjXO52aXbtm9q/JEey5uDjzADs/zXEo7bU9iX+M6glAUA==}
@ -4443,10 +4392,6 @@ snapshots:
mkdirp: 2.1.6
path-browserify: 1.0.1
'@types/accepts@1.3.7':
dependencies:
'@types/node': 22.15.33
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 22.15.33
@ -4455,24 +4400,6 @@ snapshots:
dependencies:
'@types/node': 24.0.4
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 22.15.33
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.15.33
'@types/content-disposition@0.5.9': {}
'@types/cookies@0.9.1':
dependencies:
'@types/connect': 3.4.38
'@types/express': 5.0.3
'@types/keygrip': 1.0.6
'@types/node': 22.15.33
'@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {}
@ -4501,42 +4428,6 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.0.6':
dependencies:
'@types/node': 22.15.33
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 0.17.5
'@types/express@5.0.3':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 5.0.6
'@types/serve-static': 1.15.8
'@types/http-assert@1.5.6': {}
'@types/http-errors@2.0.5': {}
'@types/keygrip@1.0.6': {}
'@types/koa-compose@3.2.8':
dependencies:
'@types/koa': 2.15.0
'@types/koa@2.15.0':
dependencies:
'@types/accepts': 1.3.7
'@types/content-disposition': 0.5.9
'@types/cookies': 0.9.1
'@types/http-assert': 1.5.6
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.8
'@types/node': 22.15.33
'@types/mime@1.3.5': {}
'@types/node@22.15.30':
dependencies:
undici-types: 6.21.0
@ -4551,10 +4442,6 @@ snapshots:
'@types/prop-types@15.7.14': {}
'@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {}
'@types/react-dom@18.3.7(@types/react@18.3.23)':
dependencies:
'@types/react': 18.3.23
@ -4564,17 +4451,6 @@ snapshots:
'@types/prop-types': 15.7.14
csstype: 3.1.3
'@types/send@0.17.5':
dependencies:
'@types/mime': 1.3.5
'@types/node': 22.15.33
'@types/serve-static@1.15.8':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 22.15.33
'@types/send': 0.17.5
'@types/tiny-async-pool@1.0.5': {}
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)':
@ -4683,6 +4559,14 @@ snapshots:
optionalDependencies:
vite: 5.4.19(@types/node@22.15.30)
'@vitest/mocker@2.1.9(vite@5.4.19(@types/node@22.15.33))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.19(@types/node@22.15.33)
'@vitest/pretty-format@2.1.9':
dependencies:
tinyrainbow: 1.2.0
@ -6377,6 +6261,24 @@ snapshots:
- supports-color
- terser
vite-node@2.1.9(@types/node@22.15.33):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.19(@types/node@22.15.33)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.19(@types/node@22.15.30):
dependencies:
esbuild: 0.21.5
@ -6386,6 +6288,15 @@ snapshots:
'@types/node': 22.15.30
fsevents: 2.3.3
vite@5.4.19(@types/node@22.15.33):
dependencies:
esbuild: 0.21.5
postcss: 8.5.4
rollup: 4.42.0
optionalDependencies:
'@types/node': 22.15.33
fsevents: 2.3.3
vitest@2.1.9(@types/node@22.15.30):
dependencies:
'@vitest/expect': 2.1.9
@ -6421,6 +6332,41 @@ snapshots:
- supports-color
- terser
vitest@2.1.9(@types/node@22.15.33):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@22.15.33))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.2.0
debug: 4.4.1
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 1.1.2
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinypool: 1.1.0
tinyrainbow: 1.2.0
vite: 5.4.19(@types/node@22.15.33)
vite-node: 2.1.9(@types/node@22.15.33)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.15.33
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4