Compare commits

..

No commits in common. "aff51b651cbbf9d9dde5603d39a5ac9f0f532d5d" and "03940cd8fd8a0f45802d60467304d02c7b824db2" have entirely different histories.

41 changed files with 795 additions and 675 deletions

9
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
# Bun doesn't run well on Musl but this seems to work
FROM oven/bun:1.2.15-alpine as base
# Switch to Bash by editing /etc/passwd
RUN apk add --no-cache libstdc++ git bash curl openssh cloc && \
sed -i -e 's|/bin/ash|/bin/bash|g' /etc/passwd
# Extract Node from its docker image (node:22-alpine)
COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node

View file

@ -0,0 +1,34 @@
{
"name": "versia Dev Container",
"dockerFile": "Dockerfile",
"runArgs": [
"-v",
"${localWorkspaceFolder}/config:/workspace/config",
"-v",
"${localWorkspaceFolder}/logs:/workspace/logs",
"-v",
"${localWorkspaceFolder}/uploads:/workspace/uploads",
"--network=host"
],
"mounts": [
"source=node_modules,target=/workspace/node_modules,type=bind,consistency=cached",
"type=bind,source=/home/${localEnv:USER}/.ssh,target=/root/.ssh,readonly"
],
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.shell.linux": "/bin/bash"
},
"extensions": [
"biomejs.biome",
"ms-vscode-remote.remote-containers",
"oven.bun-vscode",
"vivaxy.vscode-conventional-commits",
"EditorConfig.EditorConfig",
"tamasfe.even-better-toml",
"YoavBls.pretty-ts-errors",
"eamodio.gitlens"
]
}
}
}

View file

@ -429,28 +429,31 @@ text = "No spam"
[logging] [logging]
# Available levels: trace, debug, info, warning, error, fatal # Available levels: debug, info, warning, error, fatal
log_level = "info" # For console output log_level = "debug"
log_file_path = "logs/versia.log"
[logging.types]
# Either pass a boolean
# requests = true
# Or a table with the following keys:
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
# Available types are: requests, responses, requests_content, filters
# [logging.file]
# path = "logs/versia.log"
# log_level = "info"
#
# [logging.file.rotation]
# max_size = 10_000_000 # 10 MB
# max_files = 10 # Keep 10 rotated files
#
# https://sentry.io support # https://sentry.io support
# Uncomment to enable
# [logging.sentry] # [logging.sentry]
# Sentry DSN for error logging
# dsn = "https://example.com" # dsn = "https://example.com"
# debug = false # debug = false
# sample_rate = 1.0 # sample_rate = 1.0
# traces_sample_rate = 1.0 # traces_sample_rate = 1.0
# Can also be regex # Can also be regex
# trace_propagation_targets = [] # trace_propagation_targets = []
# max_breadcrumbs = 100 # max_breadcrumbs = 100
# environment = "production" # environment = "production"
# log_level = "info"
[plugins] [plugins]
# Whether to automatically load all plugins in the plugins directory # Whether to automatically load all plugins in the plugins directory

View file

@ -9,7 +9,7 @@
### Backend ### Backend
- [x] 🚀 Upgraded Bun to `1.2.17` - [x] 🚀 Upgraded Bun to `1.2.15`
# `0.8.0` • Federation 2: Electric Boogaloo # `0.8.0` • Federation 2: Electric Boogaloo

View file

@ -26,7 +26,7 @@ RUN bun run build
WORKDIR /temp/dist WORKDIR /temp/dist
# Copy production dependencies and source code into final image # Copy production dependencies and source code into final image
FROM oven/bun:1.2.17-alpine FROM oven/bun:1.2.15-alpine
# Install libstdc++ for Bun and create app directory # Install libstdc++ for Bun and create app directory
RUN apk add --no-cache libstdc++ && \ RUN apk add --no-cache libstdc++ && \

View file

@ -26,7 +26,7 @@ RUN bun run build:worker
WORKDIR /temp/dist WORKDIR /temp/dist
# Copy production dependencies and source code into final image # Copy production dependencies and source code into final image
FROM oven/bun:1.2.17-alpine FROM oven/bun:1.2.15-alpine
# Install libstdc++ for Bun and create app directory # Install libstdc++ for Bun and create app directory
RUN apk add --no-cache libstdc++ && \ RUN apk add --no-cache libstdc++ && \

View file

@ -6,6 +6,9 @@ import {
} from "@versia-server/tests"; } from "@versia-server/tests";
import { bench, run } from "mitata"; import { bench, run } from "mitata";
import type { z } from "zod"; import type { z } from "zod";
import { configureLoggers } from "@/loggers";
await configureLoggers(true);
const { users, tokens, deleteUsers } = await getTestUsers(5); const { users, tokens, deleteUsers } = await getTestUsers(5);
await getTestStatuses(40, users[0]); await getTestStatuses(40, users[0]);

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.0.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.0.0-beta.5/schema.json",
"assist": { "assist": {
"actions": { "actions": {
"source": { "source": {
@ -49,6 +49,7 @@
} }
}, },
"useLiteralEnumMembers": "error", "useLiteralEnumMembers": "error",
"noCommaOperator": "error",
"noNegationElse": "error", "noNegationElse": "error",
"noYodaExpression": "error", "noYodaExpression": "error",
"useBlockStatements": "error", "useBlockStatements": "error",
@ -88,6 +89,7 @@
"accessibility": "explicit" "accessibility": "explicit"
} }
}, },
"noArguments": "error",
"useImportType": "error", "useImportType": "error",
"useExportType": "error", "useExportType": "error",
"noUselessElse": "error", "noUselessElse": "error",
@ -141,9 +143,7 @@
"noUselessEscapeInRegex": "warn", "noUselessEscapeInRegex": "warn",
"useSimplifiedLogicExpression": "error", "useSimplifiedLogicExpression": "error",
"useWhile": "error", "useWhile": "error",
"useNumericLiterals": "error", "useNumericLiterals": "error"
"noArguments": "error",
"noCommaOperator": "error"
}, },
"suspicious": { "suspicious": {
"noDuplicateTestHooks": "error", "noDuplicateTestHooks": "error",

519
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import { getLogger, type Logger } from "@logtape/logtape";
import { EntitySorter, type JSONObject } from "@versia/sdk"; import { EntitySorter, type JSONObject } from "@versia/sdk";
import { verify } from "@versia/sdk/crypto"; import { verify } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities"; import * as VersiaEntities from "@versia/sdk/entities";
@ -12,13 +13,13 @@ import {
User, User,
} from "@versia-server/kit/db"; } from "@versia-server/kit/db";
import { Likes, Notes } from "@versia-server/kit/tables"; import { Likes, Notes } from "@versia-server/kit/tables";
import { federationInboxLogger } from "@versia-server/logging";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { Glob } from "bun"; import { Glob } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { isValidationError } from "zod-validation-error"; import { isValidationError } from "zod-validation-error";
import { sentry } from "@/sentry";
/** /**
* Checks if the hostname is defederated using glob matching. * Checks if the hostname is defederated using glob matching.
@ -64,6 +65,7 @@ export class InboxProcessor {
key: CryptoKey; key: CryptoKey;
} | null, } | null,
private authorizationHeader?: string, private authorizationHeader?: string,
private logger: Logger = getLogger(["federation", "inbox"]),
private requestIp: SocketAddress | null = null, private requestIp: SocketAddress | null = null,
) {} ) {}
@ -154,7 +156,7 @@ export class InboxProcessor {
*/ */
public async process(): Promise<void> { public async process(): Promise<void> {
!this.sender && !this.sender &&
federationInboxLogger.debug`Processing request from potential bridge`; this.logger.debug`Processing request from potential bridge`;
if (this.sender && isDefederated(this.sender.instance.data.baseUrl)) { if (this.sender && isDefederated(this.sender.instance.data.baseUrl)) {
// Return 201 to avoid // Return 201 to avoid
@ -163,15 +165,15 @@ export class InboxProcessor {
return; return;
} }
federationInboxLogger.debug`Instance ${chalk.gray( this.logger.debug`Instance ${chalk.gray(
this.sender?.instance.data.baseUrl, this.sender?.instance.data.baseUrl,
)} is not defederated`; )} is not defederated`;
const shouldCheckSignature = this.shouldCheckSignature(); const shouldCheckSignature = this.shouldCheckSignature();
shouldCheckSignature shouldCheckSignature
? federationInboxLogger.debug`Checking signature` ? this.logger.debug`Checking signature`
: federationInboxLogger.debug`Skipping signature check`; : this.logger.debug`Skipping signature check`;
if (shouldCheckSignature) { if (shouldCheckSignature) {
const isValid = await this.isSignatureValid(); const isValid = await this.isSignatureValid();
@ -181,7 +183,7 @@ export class InboxProcessor {
} }
} }
shouldCheckSignature && federationInboxLogger.debug`Signature is valid`; shouldCheckSignature && this.logger.debug`Signature is valid`;
try { try {
await new EntitySorter(this.body) await new EntitySorter(this.body)
@ -594,7 +596,8 @@ export class InboxProcessor {
throw new ApiError(400, "Failed to process request", e.message); throw new ApiError(400, "Failed to process request", e.message);
} }
federationInboxLogger.error`${e}`; this.logger.error`${e}`;
sentry?.captureException(e);
throw new ApiError(500, "Failed to process request", e.message); throw new ApiError(500, "Failed to process request", e.message);
} }

View file

@ -1,7 +1,7 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { getLogger, type Logger } from "@logtape/logtape";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { type Manifest, manifestSchema, Plugin } from "@versia-server/kit"; import { type Manifest, manifestSchema, Plugin } from "@versia-server/kit";
import { pluginLogger, serverLogger } from "@versia-server/logging";
import { file, sleep } from "bun"; import { file, sleep } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { parseJSON5, parseJSONC } from "confbox"; import { parseJSON5, parseJSONC } from "confbox";
@ -14,6 +14,8 @@ import type { HonoEnv } from "~/types/api";
* Class to manage plugins. * Class to manage plugins.
*/ */
export class PluginLoader { export class PluginLoader {
private logger = getLogger("plugin");
/** /**
* Get all directories in a given directory. * Get all directories in a given directory.
* @param {string} dir - The directory to search. * @param {string} dir - The directory to search.
@ -72,7 +74,8 @@ export class PluginLoader {
throw new Error(`Unsupported manifest file type: ${manifestFile}`); throw new Error(`Unsupported manifest file type: ${manifestFile}`);
} catch (e) { } catch (e) {
pluginLogger.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`; this.logger
.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`;
throw e; throw e;
} }
} }
@ -126,7 +129,8 @@ export class PluginLoader {
const result = await manifestSchema.safeParseAsync(manifest); const result = await manifestSchema.safeParseAsync(manifest);
if (!result.success) { if (!result.success) {
pluginLogger.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`; this.logger
.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`;
throw fromZodError(result.error); throw fromZodError(result.error);
} }
@ -150,7 +154,8 @@ export class PluginLoader {
return plugin; return plugin;
} }
pluginLogger.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`; this.logger
.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`;
throw new Error("Entrypoint is not a Plugin"); throw new Error("Entrypoint is not a Plugin");
} }
@ -172,7 +177,8 @@ export class PluginLoader {
const disabledOn = (disabled?.length ?? 0) > 0; const disabledOn = (disabled?.length ?? 0) > 0;
if (enabledOn && disabledOn) { if (enabledOn && disabledOn) {
pluginLogger.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`; this.logger
.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`;
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
} }
@ -213,9 +219,10 @@ export class PluginLoader {
plugin: Plugin<ZodTypeAny>; plugin: Plugin<ZodTypeAny>;
}[], }[],
app: Hono<HonoEnv>, app: Hono<HonoEnv>,
logger: Logger,
): Promise<void> { ): Promise<void> {
for (const data of plugins) { for (const data of plugins) {
serverLogger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`; logger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`;
const time1 = performance.now(); const time1 = performance.now();
@ -225,13 +232,13 @@ export class PluginLoader {
config.plugins?.config?.[data.manifest.name], config.plugins?.config?.[data.manifest.name],
); );
} catch (e) { } catch (e) {
serverLogger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`; logger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`;
serverLogger.fatal`This is due to invalid, missing or incomplete configuration.`; logger.fatal`This is due to invalid, missing or incomplete configuration.`;
serverLogger.fatal`Put your configuration at ${chalk.blueBright( logger.fatal`Put your configuration at ${chalk.blueBright(
"plugins.config.<plugin-name>", "plugins.config.<plugin-name>",
)}`; )}`;
serverLogger.fatal`Here is the error message, please fix the configuration file accordingly:`; logger.fatal`Here is the error message, please fix the configuration file accordingly:`;
serverLogger.fatal`${(e as ValidationError).message}`; logger.fatal`${(e as ValidationError).message}`;
await sleep(Number.POSITIVE_INFINITY); await sleep(Number.POSITIVE_INFINITY);
} }
@ -243,7 +250,7 @@ export class PluginLoader {
const time3 = performance.now(); const time3 = performance.now();
serverLogger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright( logger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(
data.manifest.version, data.manifest.version,
)} loaded in ${chalk.gray( )} loaded in ${chalk.gray(
`${(time2 - time1).toFixed(2)}ms`, `${(time2 - time1).toFixed(2)}ms`,

View file

@ -1,3 +1,4 @@
import { getLogger } from "@logtape/logtape";
import type { JSONObject } from "@versia/sdk"; import type { JSONObject } from "@versia/sdk";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
@ -63,6 +64,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
data, data,
null, null,
headers.authorization, headers.authorization,
getLogger(["federation", "inbox"]),
ip, ip,
); );
@ -158,6 +160,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
key, key,
}, },
undefined, undefined,
getLogger(["federation", "inbox"]),
ip, ip,
); );

View file

@ -3,9 +3,9 @@
* @description Sonic search integration for indexing and searching accounts and statuses * @description Sonic search integration for indexing and searching accounts and statuses
*/ */
import { getLogger } from "@logtape/logtape";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { db, Note, User } from "@versia-server/kit/db"; import { db, Note, User } from "@versia-server/kit/db";
import { sonicLogger } from "@versia-server/logging";
import type { SQL, ValueOrArray } from "drizzle-orm"; import type { SQL, ValueOrArray } from "drizzle-orm";
import { import {
Ingest as SonicChannelIngest, Ingest as SonicChannelIngest,
@ -27,6 +27,7 @@ export class SonicSearchManager {
private searchChannel: SonicChannelSearch; private searchChannel: SonicChannelSearch;
private ingestChannel: SonicChannelIngest; private ingestChannel: SonicChannelIngest;
private connected = false; private connected = false;
private logger = getLogger("sonic");
/** /**
* @param config Configuration for Sonic * @param config Configuration for Sonic
@ -54,7 +55,7 @@ export class SonicSearchManager {
*/ */
public async connect(silent = false): Promise<void> { public async connect(silent = false): Promise<void> {
if (!config.search.enabled) { if (!config.search.enabled) {
!silent && sonicLogger.info`Sonic search is disabled`; !silent && this.logger.info`Sonic search is disabled`;
return; return;
} }
@ -62,24 +63,28 @@ export class SonicSearchManager {
return; return;
} }
!silent && sonicLogger.info`Connecting to Sonic...`; !silent && this.logger.info`Connecting to Sonic...`;
// Connect to Sonic // Connect to Sonic
await new Promise<boolean>((resolve, reject) => { await new Promise<boolean>((resolve, reject) => {
this.searchChannel.connect({ this.searchChannel.connect({
connected: (): void => { connected: (): void => {
!silent && !silent &&
sonicLogger.info`Connected to Sonic Search Channel`; this.logger.info`Connected to Sonic Search Channel`;
resolve(true); resolve(true);
}, },
disconnected: (): void => disconnected: (): void =>
sonicLogger.error`Disconnected from Sonic Search Channel. You might be using an incorrect password.`, this.logger
.error`Disconnected from Sonic Search Channel. You might be using an incorrect password.`,
timeout: (): void => timeout: (): void =>
sonicLogger.error`Sonic Search Channel connection timed out`, this.logger
.error`Sonic Search Channel connection timed out`,
retrying: (): void => retrying: (): void =>
sonicLogger.warn`Retrying connection to Sonic Search Channel`, this.logger
.warn`Retrying connection to Sonic Search Channel`,
error: (error): void => { error: (error): void => {
sonicLogger.error`Failed to connect to Sonic Search Channel: ${error}`; this.logger
.error`Failed to connect to Sonic Search Channel: ${error}`;
reject(error); reject(error);
}, },
}); });
@ -89,17 +94,20 @@ export class SonicSearchManager {
this.ingestChannel.connect({ this.ingestChannel.connect({
connected: (): void => { connected: (): void => {
!silent && !silent &&
sonicLogger.info`Connected to Sonic Ingest Channel`; this.logger.info`Connected to Sonic Ingest Channel`;
resolve(true); resolve(true);
}, },
disconnected: (): void => disconnected: (): void =>
sonicLogger.error`Disconnected from Sonic Ingest Channel`, this.logger.error`Disconnected from Sonic Ingest Channel`,
timeout: (): void => timeout: (): void =>
sonicLogger.error`Sonic Ingest Channel connection timed out`, this.logger
.error`Sonic Ingest Channel connection timed out`,
retrying: (): void => retrying: (): void =>
sonicLogger.warn`Retrying connection to Sonic Ingest Channel`, this.logger
.warn`Retrying connection to Sonic Ingest Channel`,
error: (error): void => { error: (error): void => {
sonicLogger.error`Failed to connect to Sonic Ingest Channel: ${error}`; this.logger
.error`Failed to connect to Sonic Ingest Channel: ${error}`;
reject(error); reject(error);
}, },
}); });
@ -111,9 +119,9 @@ export class SonicSearchManager {
this.ingestChannel.ping(), this.ingestChannel.ping(),
]); ]);
this.connected = true; this.connected = true;
!silent && sonicLogger.info`Connected to Sonic`; !silent && this.logger.info`Connected to Sonic`;
} catch (error) { } catch (error) {
sonicLogger.fatal`Error while connecting to Sonic: ${error}`; this.logger.fatal`Error while connecting to Sonic: ${error}`;
throw error; throw error;
} }
} }
@ -135,7 +143,7 @@ export class SonicSearchManager {
`${user.data.username} ${user.data.displayName} ${user.data.note}`, `${user.data.username} ${user.data.displayName} ${user.data.note}`,
); );
} catch (error) { } catch (error) {
sonicLogger.error`Failed to add user to Sonic: ${error}`; this.logger.error`Failed to add user to Sonic: ${error}`;
} }
} }

View file

@ -435,29 +435,31 @@ text = "No spam"
[logging] [logging]
# Available levels: trace, debug, info, warning, error, fatal # Available levels: debug, info, warning, error, fatal
log_level = "info" # For console output log_level = "debug"
log_file_path = "logs/versia.log"
[logging.types]
# Either pass a boolean
# requests = true
# Or a table with the following keys:
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
# Available types are: requests, responses, requests_content, filters
# [logging.file]
# path = "logs/versia.log"
# log_level = "info"
#
# [logging.file.rotation]
# max_size = 10_000_000 # 10 MB
# max_files = 10 # Keep 10 rotated files
#
# https://sentry.io support # https://sentry.io support
# Uncomment to enable
# [logging.sentry] # [logging.sentry]
# Sentry DSN for error logging
# dsn = "https://example.com" # dsn = "https://example.com"
# debug = false # debug = false
# sample_rate = 1.0 # sample_rate = 1.0
# traces_sample_rate = 1.0 # traces_sample_rate = 1.0
# Can also be regex # Can also be regex
# trace_propagation_targets = [] # trace_propagation_targets = []
# max_breadcrumbs = 100 # max_breadcrumbs = 100
# environment = "production" # environment = "production"
# log_level = "info"
[plugins] [plugins]
# Whether to automatically load all plugins in the plugins directory # Whether to automatically load all plugins in the plugins directory

View file

@ -20,32 +20,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1750365781, "lastModified": 1749903597,
"narHash": "sha256-XE/lFNhz5lsriMm/yjXkvSZz5DfvKJLUjsS6pP8EC50=", "narHash": "sha256-jp0D4vzBcRKwNZwfY4BcWHemLGUs4JrS3X9w5k/JYDA=",
"owner": "NixOS", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "08f22084e6085d19bcfb4be30d1ca76ecb96fe54", "rev": "41da1e3ea8e23e094e5e3eeb1e6b830468a7399e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "nixos",
"ref": "nixos-unstable", "ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-bun": {
"locked": {
"lastModified": 1749427688,
"narHash": "sha256-mMfhQsEYlfOvtjMs6BNPduuRc4YP/+Mj3G+/KYLQLUw=",
"owner": "0xdsqr",
"repo": "nixpkgs",
"rev": "09f139e43b59756fcbd9437b3a8726238fa62880",
"type": "github"
},
"original": {
"owner": "0xdsqr",
"ref": "add-bun-support",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@ -53,8 +37,7 @@
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs"
"nixpkgs-bun": "nixpkgs-bun"
} }
}, },
"systems": { "systems": {

View file

@ -2,8 +2,7 @@
description = "Versia Server"; description = "Versia Server";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
nixpkgs-bun.url = "github:0xdsqr/nixpkgs/add-bun-support";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
@ -11,21 +10,12 @@
outputs = { outputs = {
self, self,
nixpkgs, nixpkgs,
nixpkgs-bun,
flake-utils, flake-utils,
... ...
}: }:
{ {
overlays.default = final: prev: rec { overlays.default = final: prev: rec {
versia-server = final.callPackage ./nix/package.nix { versia-server = final.callPackage ./nix/package.nix {};
inherit
(nixpkgs-bun.legacyPackages.x86_64-linux)
fetchBunDeps
bunConfigHook
bunInstallHook
bunBuildHook
;
};
versia-server-worker = final.callPackage ./nix/package-worker.nix { versia-server-worker = final.callPackage ./nix/package-worker.nix {
inherit versia-server; inherit versia-server;
}; };

View file

@ -1,14 +1,11 @@
{ {
lib, lib,
stdenv, stdenv,
pnpm,
bun, bun,
nodejs, nodejs,
vips, vips,
makeWrapper, makeWrapper,
fetchBunDeps,
bunConfigHook,
bunInstallHook,
bunBuildHook,
... ...
}: let }: let
packageJson = builtins.fromJSON (builtins.readFile ../package.json); packageJson = builtins.fromJSON (builtins.readFile ../package.json);
@ -19,22 +16,34 @@ in
src = ../.; src = ../.;
bunOfflineCache = fetchBunDeps { # Fixes the build script mv usage
bunLock = finalAttrs.src + "/bun.lock"; pnpmInstallFlags = ["--shamefully-hoist"];
hash = "sha256-8R+LzgqAiqRGCMDBw2R7QO6hbdNrtIwzSjR3A8xhfVw=";
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src pnpmInstallFlags;
hash = "sha256-nC1bYW+It2N0Mp8+Yh1uk3MOj8DABOCNP5E3LbMuCEQ=";
}; };
bunBuildScript = "packages/api/build.ts";
nativeBuildInputs = [ nativeBuildInputs = [
pnpm
pnpm.configHook
bun bun
nodejs nodejs
makeWrapper makeWrapper
bunConfigHook
bunInstallHook
bunBuildHook
]; ];
buildInputs = [
vips
];
buildPhase = ''
runHook preBuild
bun run packages/api/build.ts
runHook postBuild
'';
entrypointPath = "packages/api/index.js"; entrypointPath = "packages/api/index.js";
installPhase = let installPhase = let

View file

@ -25,8 +25,8 @@
"packages/*" "packages/*"
], ],
"catalog": { "catalog": {
"@biomejs/biome": "^2.0.4", "@biomejs/biome": "2.0.0-beta.5",
"@types/bun": "^1.2.17", "@types/bun": "^1.2.16",
"@types/html-to-text": "^9.0.4", "@types/html-to-text": "^9.0.4",
"@types/markdown-it-container": "^2.0.10", "@types/markdown-it-container": "^2.0.10",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
@ -41,7 +41,7 @@
"vitepress": "^1.6.3", "vitepress": "^1.6.3",
"vitepress-plugin-tabs": "^0.7.1", "vitepress-plugin-tabs": "^0.7.1",
"vitepress-sidebar": "^1.31.1", "vitepress-sidebar": "^1.31.1",
"vue": "^3.5.17", "vue": "^3.5.16",
"zod-to-json-schema": "^3.24.5", "zod-to-json-schema": "^3.24.5",
"@bull-board/api": "^6.10.1", "@bull-board/api": "^6.10.1",
"@bull-board/hono": "^6.10.1", "@bull-board/hono": "^6.10.1",
@ -53,21 +53,19 @@
"@hackmd/markdown-it-task-lists": "^2.1.4", "@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/zod-validator": "^0.7.0", "@hono/zod-validator": "^0.7.0",
"@inquirer/confirm": "^5.1.12", "@inquirer/confirm": "^5.1.12",
"@logtape/file": "^1.0.0", "@logtape/file": "^0.12.0",
"@logtape/logtape": "^1.0.0", "@logtape/logtape": "^0.12.0",
"@logtape/sentry": "^1.0.0", "@scalar/hono-api-reference": "^0.9.4",
"@logtape/otel": "^1.0.0",
"@scalar/hono-api-reference": "^0.9.6",
"@sentry/bun": "^9.29.0", "@sentry/bun": "^9.29.0",
"altcha-lib": "^1.3.0", "altcha-lib": "^1.3.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"bullmq": "^5.55.0", "bullmq": "^5.53.3",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"clerc": "^0.44.0", "clerc": "^0.44.0",
"confbox": "^0.2.2", "confbox": "^0.2.2",
"drizzle-orm": "^0.44.2", "drizzle-orm": "^0.44.2",
"feed": "^5.1.0", "feed": "^5.1.0",
"hono": "^4.8.2", "hono": "^4.7.11",
"hono-openapi": "^0.4.8", "hono-openapi": "^0.4.8",
"hono-rate-limiter": "^0.4.2", "hono-rate-limiter": "^0.4.2",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
@ -100,7 +98,7 @@
"youch": "^4.1.0-beta.7", "youch": "^4.1.0-beta.7",
"zod": "^3.25.64", "zod": "^3.25.64",
"zod-openapi": "^4.2.4", "zod-openapi": "^4.2.4",
"zod-validation-error": "^3.5.2" "zod-validation-error": "^3.5.0"
} }
}, },
"maintainers": [ "maintainers": [
@ -132,7 +130,6 @@
"es5-ext", "es5-ext",
"esbuild", "esbuild",
"msgpackr-extract", "msgpackr-extract",
"protobufjs",
"sharp" "sharp"
], ],
"devDependencies": { "devDependencies": {
@ -165,14 +162,15 @@
"@hackmd/markdown-it-task-lists": "catalog:", "@hackmd/markdown-it-task-lists": "catalog:",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@inquirer/confirm": "catalog:", "@inquirer/confirm": "catalog:",
"@logtape/file": "catalog:",
"@logtape/logtape": "catalog:",
"@scalar/hono-api-reference": "catalog:", "@scalar/hono-api-reference": "catalog:",
"@sentry/bun": "catalog:", "@sentry/bun": "catalog:",
"@versia-server/config": "workspace:*",
"@versia-server/kit": "workspace:*",
"@versia-server/tests": "workspace:*",
"@versia-server/logging": "workspace:*",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia-server/kit": "workspace:*",
"@versia/sdk": "workspace:*", "@versia/sdk": "workspace:*",
"@versia-server/tests": "workspace:*",
"@versia-server/config": "workspace:*",
"altcha-lib": "catalog:", "altcha-lib": "catalog:",
"blurhash": "catalog:", "blurhash": "catalog:",
"bullmq": "catalog:", "bullmq": "catalog:",
@ -187,7 +185,7 @@
"html-to-text": "catalog:", "html-to-text": "catalog:",
"ioredis": "catalog:", "ioredis": "catalog:",
"ip-matching": "catalog:", "ip-matching": "catalog:",
"iso-639-1": "catalog:", "iso-639-1": "^3.1.5",
"jose": "catalog:", "jose": "catalog:",
"linkify-html": "catalog:", "linkify-html": "catalog:",
"linkify-string": "catalog:", "linkify-string": "catalog:",

View file

@ -1,8 +1,8 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { getLogger } from "@logtape/logtape";
import { Scalar } from "@scalar/hono-api-reference"; import { Scalar } from "@scalar/hono-api-reference";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { serverLogger } from "@versia-server/logging";
import chalk from "chalk"; import chalk from "chalk";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
@ -13,6 +13,8 @@ import { secureHeaders } from "hono/secure-headers";
import { openAPISpecs } from "hono-openapi"; import { openAPISpecs } from "hono-openapi";
import { Youch } from "youch"; import { Youch } from "youch";
import { applyToHono } from "@/bull-board.ts"; import { applyToHono } from "@/bull-board.ts";
import { configureLoggers } from "@/loggers";
import { sentry } from "@/sentry";
import pkg from "~/package.json" with { type: "application/json" }; import pkg from "~/package.json" with { type: "application/json" };
import { PluginLoader } from "../../classes/plugin/loader.ts"; import { PluginLoader } from "../../classes/plugin/loader.ts";
import type { ApiRouteExports, HonoEnv } from "../../types/api.ts"; import type { ApiRouteExports, HonoEnv } from "../../types/api.ts";
@ -26,6 +28,9 @@ import { routes } from "./routes.ts";
import "zod-openapi/extend"; import "zod-openapi/extend";
export const appFactory = async (): Promise<Hono<HonoEnv>> => { export const appFactory = async (): Promise<Hono<HonoEnv>> => {
await configureLoggers();
const serverLogger = getLogger("server");
const app = new Hono<HonoEnv>({ const app = new Hono<HonoEnv>({
strict: false, strict: false,
}); });
@ -119,7 +124,7 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
config.plugins?.overrides.disabled, config.plugins?.overrides.disabled,
); );
await PluginLoader.addToApp(plugins, app); await PluginLoader.addToApp(plugins, app, serverLogger);
const time2 = performance.now(); const time2 = performance.now();
@ -188,6 +193,7 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
const youch = new Youch(); const youch = new Youch();
console.error(await youch.toANSI(error)); console.error(await youch.toANSI(error));
sentry?.captureException(error);
return c.json( return c.json(
{ {
error: "A server error occured", error: "A server error occured",

View file

@ -1,6 +1,7 @@
import process from "node:process"; import process from "node:process";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { Youch } from "youch"; import { Youch } from "youch";
import { sentry } from "@/sentry";
import { createServer } from "@/server"; import { createServer } from "@/server";
import { appFactory } from "./app.ts"; import { appFactory } from "./app.ts";
@ -15,5 +16,6 @@ process.on("uncaughtException", async (error) => {
}); });
await import("./setup.ts"); await import("./setup.ts");
sentry?.captureMessage("Server started", "info");
createServer(config, await appFactory()); createServer(config, await appFactory());

View file

@ -1,9 +1,10 @@
import { getLogger } from "@logtape/logtape";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { serverLogger } from "@versia-server/logging";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { sentry } from "@/sentry";
export const ipBans = createMiddleware(async (context, next) => { export const ipBans = createMiddleware(async (context, next) => {
// Check for banned IPs // Check for banned IPs
@ -21,8 +22,11 @@ export const ipBans = createMiddleware(async (context, next) => {
throw new ApiError(403, "Forbidden"); throw new ApiError(403, "Forbidden");
} }
} catch (e) { } catch (e) {
serverLogger.error`Error while parsing banned IP "${ip}" `; const logger = getLogger("server");
serverLogger.error`${e}`;
logger.error`Error while parsing banned IP "${ip}" `;
logger.error`${e}`;
sentry?.captureException(e);
return context.json( return context.json(
{ error: `A server error occured: ${(e as Error).message}` }, { error: `A server error occured: ${(e as Error).message}` },

View file

@ -1,26 +1,37 @@
import { serverLogger } from "@versia-server/logging"; import { getLogger } from "@logtape/logtape";
import { config } from "@versia-server/config";
import { SHA256 } from "bun"; import { SHA256 } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
export const logger = createMiddleware(async (context, next) => { export const logger = createMiddleware(async (context, next) => {
const body = await context.req.raw.clone().text(); if (config.logging.types.requests) {
const serverLogger = getLogger("server");
const body = await context.req.raw.clone().text();
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`; const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
const hash = `${chalk.bold("Hash")}: ${chalk.yellow( const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
new SHA256().update(body).digest("hex"), new SHA256().update(body).digest("hex"),
)}`; )}`;
const headers = `${chalk.bold("Headers")}:\n${Array.from( const headers = `${chalk.bold("Headers")}:\n${Array.from(
context.req.raw.headers.entries(), context.req.raw.headers.entries(),
) )
.map(([key, value]) => ` - ${chalk.cyan(key)}: ${chalk.white(value)}`) .map(
.join("\n")}`; ([key, value]) =>
` - ${chalk.cyan(key)}: ${chalk.white(value)}`,
)
.join("\n")}`;
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`; const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`; if (config.logging.types.requests_content) {
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
} else {
serverLogger.debug`${urlAndMethod}`;
}
}
await next(); await next();
}); });

View file

@ -45,9 +45,9 @@
"@versia-server/config": "workspace:*", "@versia-server/config": "workspace:*",
"@versia-server/tests": "workspace:*", "@versia-server/tests": "workspace:*",
"@versia-server/kit": "workspace:*", "@versia-server/kit": "workspace:*",
"@versia-server/logging": "workspace:*",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia/sdk": "workspace:*", "@versia/sdk": "workspace:*",
"@logtape/logtape": "catalog:",
"youch": "catalog:", "youch": "catalog:",
"hono": "catalog:", "hono": "catalog:",
"hono-openapi": "catalog:", "hono-openapi": "catalog:",

View file

@ -1,5 +1,5 @@
import { getLogger } from "@logtape/logtape";
import { apiRoute } from "@versia-server/kit/api"; import { apiRoute } from "@versia-server/kit/api";
import { federationMessagingLogger } from "@versia-server/logging";
import chalk from "chalk"; import chalk from "chalk";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
@ -19,7 +19,8 @@ export default apiRoute((app) =>
async (context) => { async (context) => {
const content = await context.req.text(); const content = await context.req.text();
federationMessagingLogger.info`Received message via ${chalk.bold("Instance Messaging")}:\n${content}`; getLogger(["federation", "messaging"])
.info`Received message via ${chalk.bold("Instance Messaging")}:\n${content}`;
return context.text("", 200); return context.text("", 200);
}, },

View file

@ -1,3 +1,4 @@
import { getLogger } from "@logtape/logtape";
import { FederationRequester } from "@versia/sdk/http"; import { FederationRequester } from "@versia/sdk/http";
import { WebFingerSchema } from "@versia/sdk/schemas"; import { WebFingerSchema } from "@versia/sdk/schemas";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
@ -7,7 +8,6 @@ import { User } from "@versia-server/kit/db";
import { parseUserAddress } from "@versia-server/kit/parsers"; import { parseUserAddress } from "@versia-server/kit/parsers";
import { uuid, webfingerMention } from "@versia-server/kit/regex"; import { uuid, webfingerMention } from "@versia-server/kit/regex";
import { Users } from "@versia-server/kit/tables"; import { Users } from "@versia-server/kit/tables";
import { federationBridgeLogger } from "@versia-server/logging";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
@ -90,7 +90,8 @@ export default apiRoute((app) =>
} catch (e) { } catch (e) {
const error = e as ApiError; const error = e as ApiError;
federationBridgeLogger.error`Error from bridge: ${error.message}`; getLogger(["federation", "bridge"])
.error`Error from bridge: ${error.message}`;
} }
} }

View file

@ -1,11 +1,16 @@
import { getLogger } from "@logtape/logtape";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { Note, setupDatabase } from "@versia-server/kit/db"; import { Note, setupDatabase } from "@versia-server/kit/db";
import { connection } from "@versia-server/kit/redis"; import { connection } from "@versia-server/kit/redis";
import { serverLogger } from "@versia-server/logging"; import { configureLoggers } from "@/loggers";
import { searchManager } from "../../classes/search/search-manager.ts"; import { searchManager } from "../../classes/search/search-manager.ts";
const timeAtStart = performance.now(); const timeAtStart = performance.now();
await configureLoggers();
const serverLogger = getLogger("server");
console.info(` console.info(`

View file

@ -716,35 +716,34 @@ export const ConfigSchema = z
admin: z.array(z.nativeEnum(RolePermission)).default(ADMIN_ROLES), admin: z.array(z.nativeEnum(RolePermission)).default(ADMIN_ROLES),
}), }),
logging: z.strictObject({ logging: z.strictObject({
file: z types: z.record(
.strictObject({ z.enum([
path: z.string().default("logs/versia.log"), "requests",
rotation: z "responses",
.strictObject({ "requests_content",
max_size: z "filters",
.number() ]),
.int() z
.nonnegative() .boolean()
.default(10_000_000), // 10 MB .default(false)
max_files: z .or(
.number() z.strictObject({
.int() level: z
.nonnegative() .enum([
.default(10), "debug",
}) "info",
.optional(), "warning",
log_level: z "error",
.enum([ "fatal",
"trace", ])
"debug", .default("info"),
"info", log_file_path: z.string().optional(),
"warning", }),
"error", ),
"fatal", ),
]) log_level: z
.default("info"), .enum(["debug", "info", "warning", "error", "fatal"])
}) .default("info"),
.optional(),
sentry: z sentry: z
.strictObject({ .strictObject({
dsn: url, dsn: url,
@ -754,21 +753,9 @@ export const ConfigSchema = z
trace_propagation_targets: z.array(z.string()).default([]), trace_propagation_targets: z.array(z.string()).default([]),
max_breadcrumbs: z.number().default(100), max_breadcrumbs: z.number().default(100),
environment: z.string().optional(), environment: z.string().optional(),
log_level: z
.enum([
"trace",
"debug",
"info",
"warning",
"error",
"fatal",
])
.default("info"),
}) })
.optional(), .optional(),
log_level: z log_file_path: z.string().default("logs/versia.log"),
.enum(["trace", "debug", "info", "warning", "error", "fatal"])
.default("info"),
}), }),
debug: z debug: z
.strictObject({ .strictObject({

View file

@ -1,56 +0,0 @@
import type { LogLevel, LogRecord } from "@logtape/logtape";
import chalk, { type ChalkInstance } from "chalk";
const levelAbbreviations: Record<LogLevel, string> = {
debug: "DBG",
info: "INF",
warning: "WRN",
error: "ERR",
fatal: "FTL",
trace: "TRC",
};
/**
* The styles for the log level in the console.
*/
const logLevelStyles: Record<LogLevel, ChalkInstance> = {
debug: chalk.white.bgGray,
info: chalk.black.bgWhite,
warning: chalk.black.bgYellow,
error: chalk.white.bgRed,
fatal: chalk.white.bgRedBright,
trace: chalk.white.bgBlue,
};
/**
* Pretty colored console formatter.
*
* @param record The log record to format.
* @returns The formatted log record, as an array of arguments for
* {@link console.log}.
*/
export function consoleFormatter(record: LogRecord): string[] {
const msg = record.message.join("");
const date = new Date(record.timestamp);
const time = `${date.getUTCHours().toString().padStart(2, "0")}:${date
.getUTCMinutes()
.toString()
.padStart(
2,
"0",
)}:${date.getUTCSeconds().toString().padStart(2, "0")}.${date
.getUTCMilliseconds()
.toString()
.padStart(3, "0")}`;
const formattedTime = chalk.gray(time);
const formattedLevel = logLevelStyles[record.level](
levelAbbreviations[record.level],
);
const formattedCategory = chalk.gray(record.category.join("\xb7"));
const formattedMsg = chalk.reset(msg);
return [
`${formattedTime} ${formattedLevel} ${formattedCategory} ${formattedMsg}`,
];
}

View file

@ -1,158 +0,0 @@
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { getFileSink, getRotatingFileSink } from "@logtape/file";
import {
configure,
getConsoleSink,
getLevelFilter,
getLogger,
type Sink,
withFilter,
} from "@logtape/logtape";
import { getSentrySink } from "@logtape/sentry";
import * as Sentry from "@sentry/bun";
import { config } from "@versia-server/config";
import { env } from "bun";
import pkg from "../../package.json" with { type: "json" };
import { consoleFormatter } from "./formatter.ts";
if (config.logging.file?.path) {
// config.logging.file.path is a path to a file, create the directory if it doesn't exist
await mkdir(dirname(config.logging.file.path), { recursive: true });
}
/**
* Returns all configured sinks depending on the configuration.
*/
const getSinks = (): Record<"file" | "console" | "sentry", Sink> => {
const sinks: Record<string, Sink> = {};
if (config.logging.file) {
if (config.logging.file.rotation) {
sinks.file = getRotatingFileSink(config.logging.file.path, {
maxFiles: config.logging.file.rotation.max_files,
maxSize: config.logging.file.rotation.max_size,
});
} else {
sinks.file = getFileSink(config.logging.file.path);
}
sinks.file = withFilter(
sinks.file,
getLevelFilter(config.logging.file.log_level),
);
}
if (config.logging.sentry) {
sinks.sentry = getSentrySink(
Sentry.init({
dsn: config.logging.sentry.dsn.origin,
debug: config.logging.sentry.debug,
sampleRate: config.logging.sentry.sample_rate,
maxBreadcrumbs: config.logging.sentry.max_breadcrumbs,
tracesSampleRate: config.logging.sentry.traces_sample_rate,
environment: config.logging.sentry.environment,
tracePropagationTargets:
config.logging.sentry.trace_propagation_targets,
release: env.GIT_COMMIT
? `${pkg.version}-${env.GIT_COMMIT}`
: pkg.version,
integrations: [Sentry.extraErrorDataIntegration()],
}),
);
sinks.sentry = withFilter(
sinks.sentry,
getLevelFilter(config.logging.sentry.log_level),
);
}
sinks.console = getConsoleSink({
formatter: consoleFormatter,
});
sinks.console = withFilter(
sinks.console,
getLevelFilter(config.logging.log_level),
);
return sinks;
};
const getSinkNames = (): ("file" | "console" | "sentry")[] => {
const names = [] as ("file" | "console" | "sentry")[];
if (config.logging.file) {
names.push("file");
}
if (config.logging.sentry) {
names.push("sentry");
}
names.push("console");
return names;
};
await configure({
reset: true,
sinks: getSinks(),
loggers: [
{
category: "server",
sinks: getSinkNames(),
},
{
category: ["federation", "inbox"],
sinks: getSinkNames(),
},
{
category: ["federation", "delivery"],
sinks: getSinkNames(),
},
{
category: ["federation", "bridge"],
sinks: getSinkNames(),
},
{
category: ["federation", "resolvers"],
sinks: getSinkNames(),
},
{
category: ["federation", "messaging"],
sinks: getSinkNames(),
},
{
category: "database",
sinks: getSinkNames(),
},
{
category: "webfinger",
sinks: getSinkNames(),
},
{
category: "sonic",
sinks: getSinkNames(),
},
{
category: ["logtape", "meta"],
lowestLevel: "error",
},
{
category: "plugin",
sinks: getSinkNames(),
},
],
});
export const serverLogger = getLogger("server");
export const federationInboxLogger = getLogger(["federation", "inbox"]);
export const federationDeliveryLogger = getLogger(["federation", "delivery"]);
export const federationBridgeLogger = getLogger(["federation", "bridge"]);
export const federationResolversLogger = getLogger(["federation", "resolvers"]);
export const federationMessagingLogger = getLogger(["federation", "messaging"]);
export const databaseLogger = getLogger("database");
export const webfingerLogger = getLogger("webfinger");
export const sonicLogger = getLogger("sonic");
export const pluginLogger = getLogger("plugin");

View file

@ -1,22 +0,0 @@
{
"name": "@versia-server/logging",
"module": "index.ts",
"type": "module",
"version": "0.0.1",
"private": true,
"exports": {
".": {
"import": "./index.ts",
"default": "./index.ts"
}
},
"dependencies": {
"@versia-server/config": "workspace:*",
"@logtape/logtape": "catalog:",
"@logtape/file": "catalog:",
"@logtape/sentry": "catalog:",
"@logtape/otel": "catalog:",
"@sentry/bun": "catalog:",
"chalk": "catalog:"
}
}

View file

@ -1,7 +1,7 @@
import type { Hook } from "@hono/zod-validator"; import type { Hook } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape";
import type { RolePermission } from "@versia/client/schemas"; import type { RolePermission } from "@versia/client/schemas";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { serverLogger } from "@versia-server/logging";
import { extractParams, verifySolution } from "altcha-lib"; import { extractParams, verifySolution } from "altcha-lib";
import chalk from "chalk"; import chalk from "chalk";
import { eq, type SQL } from "drizzle-orm"; import { eq, type SQL } from "drizzle-orm";
@ -418,6 +418,7 @@ export const jsonOrForm = (): MiddlewareHandler<HonoEnv> => {
export const debugResponse = async (res: Response): Promise<void> => { export const debugResponse = async (res: Response): Promise<void> => {
const body = await res.clone().text(); const body = await res.clone().text();
const logger = getLogger("server");
const status = `${chalk.bold("Status")}: ${chalk.green(res.status)}`; const status = `${chalk.bold("Status")}: ${chalk.green(res.status)}`;
@ -429,5 +430,9 @@ export const debugResponse = async (res: Response): Promise<void> => {
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`; const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
serverLogger.debug`${status}\n${headers}\n${bodyLog}`; if (config.logging.types.requests_content) {
logger.debug`${status}\n${headers}\n${bodyLog}`;
} else {
logger.debug`${status}`;
}
}; };

View file

@ -1,12 +1,9 @@
import { getLogger } from "@logtape/logtape";
import * as VersiaEntities from "@versia/sdk/entities"; import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { db } from "@versia-server/kit/db"; import { db } from "@versia-server/kit/db";
import { Instances } from "@versia-server/kit/tables"; import { Instances } from "@versia-server/kit/tables";
import {
federationMessagingLogger,
federationResolversLogger,
} from "@versia-server/logging";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { import {
@ -178,6 +175,9 @@ export class Instance extends BaseInterface<typeof Instances> {
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata // Go to endpoint, then follow the links to the actual metadata
const logger = getLogger(["federation", "resolvers"]);
try { try {
const { json, ok, status } = await fetch(wellKnownUrl, { const { json, ok, status } = await fetch(wellKnownUrl, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
@ -185,7 +185,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}); });
if (!ok) { if (!ok) {
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - HTTP ${status}`; )} - HTTP ${status}`;
return null; return null;
@ -196,7 +196,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}; };
if (!wellKnown.links) { if (!wellKnown.links) {
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - No links found`; )} - No links found`;
return null; return null;
@ -209,7 +209,7 @@ export class Instance extends BaseInterface<typeof Instances> {
); );
if (!metadataUrl) { if (!metadataUrl) {
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - No metadata URL found`; )} - No metadata URL found`;
return null; return null;
@ -225,7 +225,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}); });
if (!ok2) { if (!ok2) {
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - HTTP ${status2}`; )} - HTTP ${status2}`;
return null; return null;
@ -264,7 +264,7 @@ export class Instance extends BaseInterface<typeof Instances> {
}, },
}); });
} catch (error) { } catch (error) {
federationResolversLogger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - Error! ${error}`; )} - Error! ${error}`;
return null; return null;
@ -312,12 +312,14 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
public async updateFromRemote(): Promise<Instance> { public async updateFromRemote(): Promise<Instance> {
const logger = getLogger(["federation", "resolvers"]);
const output = await Instance.fetchMetadata( const output = await Instance.fetchMetadata(
new URL(`https://${this.data.baseUrl}`), new URL(`https://${this.data.baseUrl}`),
); );
if (!output) { if (!output) {
federationResolversLogger.error`Failed to update instance ${chalk.bold( logger.error`Failed to update instance ${chalk.bold(
this.data.baseUrl, this.data.baseUrl,
)}`; )}`;
throw new Error("Failed to update instance"); throw new Error("Failed to update instance");
@ -339,10 +341,12 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
public async sendMessage(content: string): Promise<void> { public async sendMessage(content: string): Promise<void> {
const logger = getLogger(["federation", "messaging"]);
if ( if (
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint !this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint
) { ) {
federationMessagingLogger.info`Instance ${chalk.gray( logger.info`Instance ${chalk.gray(
this.data.baseUrl, this.data.baseUrl,
)} does not support Instance Messaging, skipping message`; )} does not support Instance Messaging, skipping message`;

View file

@ -1,3 +1,4 @@
import { getLogger } from "@logtape/logtape";
import type { import type {
Account, Account,
Mention as MentionSchema, Mention as MentionSchema,
@ -28,10 +29,6 @@ import {
Users, Users,
UserToPinnedNotes, UserToPinnedNotes,
} from "@versia-server/kit/tables"; } from "@versia-server/kit/tables";
import {
federationDeliveryLogger,
federationResolversLogger,
} from "@versia-server/logging";
import { password as bunPassword, randomUUIDv7 } from "bun"; import { password as bunPassword, randomUUIDv7 } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { import {
@ -52,6 +49,7 @@ import { htmlToText } from "html-to-text";
import type { z } from "zod"; import type { z } from "zod";
import { getBestContentType } from "@/content_types"; import { getBestContentType } from "@/content_types";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { sentry } from "@/sentry";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import type { HttpVerb, KnownEntity } from "~/types/api.ts"; import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import { import {
@ -1167,7 +1165,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
public static async resolve(uri: URL): Promise<User | null> { public static async resolve(uri: URL): Promise<User | null> {
federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`; getLogger(["federation", "resolvers"])
.debug`Resolving user ${chalk.gray(uri)}`;
// Check if user not already in database // Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri.href)); const foundUser = await User.fromSql(eq(Users.uri, uri.href));
@ -1188,7 +1187,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return await User.fromId(userUuid[0]); return await User.fromId(userUuid[0]);
} }
federationResolversLogger.debug`User not found in database, fetching from remote`; getLogger(["federation", "resolvers"])
.debug`User not found in database, fetching from remote`;
return User.fromVersia(uri); return User.fromVersia(uri);
} }
@ -1419,10 +1419,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
entity, entity,
); );
} catch (e) { } catch (e) {
federationDeliveryLogger.error`Federating ${chalk.gray( getLogger(["federation", "delivery"]).error`Federating ${chalk.gray(
entity.data.type, entity.data.type,
)} to ${user.uri} ${chalk.bold.red("failed")}`; )} to ${user.uri} ${chalk.bold.red("failed")}`;
federationDeliveryLogger.error`${e}`; getLogger(["federation", "delivery"]).error`${e}`;
sentry?.captureException(e);
return { ok: false }; return { ok: false };
} }

View file

@ -41,9 +41,9 @@
"chalk": "catalog:", "chalk": "catalog:",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia-server/config": "workspace:*", "@versia-server/config": "workspace:*",
"@versia-server/logging": "workspace:*",
"@versia/sdk": "workspace:*", "@versia/sdk": "workspace:*",
"html-to-text": "catalog:", "html-to-text": "catalog:",
"@logtape/logtape": "catalog:",
"sharp": "catalog:", "sharp": "catalog:",
"magic-regexp": "catalog:", "magic-regexp": "catalog:",
"altcha-lib": "catalog:", "altcha-lib": "catalog:",

View file

@ -1,5 +1,5 @@
import { getLogger } from "@logtape/logtape";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { databaseLogger } from "@versia-server/logging";
import { SQL } from "bun"; import { SQL } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { type BunSQLDatabase, drizzle } from "drizzle-orm/bun-sql"; import { type BunSQLDatabase, drizzle } from "drizzle-orm/bun-sql";
@ -40,6 +40,8 @@ export const db =
: drizzle(primaryDb, { schema }); : drizzle(primaryDb, { schema });
export const setupDatabase = async (info = true): Promise<void> => { export const setupDatabase = async (info = true): Promise<void> => {
const logger = getLogger("database");
for (const dbPool of [primaryDb, ...replicas]) { for (const dbPool of [primaryDb, ...replicas]) {
try { try {
await dbPool.connect(); await dbPool.connect();
@ -51,7 +53,7 @@ export const setupDatabase = async (info = true): Promise<void> => {
return; return;
} }
databaseLogger.fatal`Failed to connect to database ${chalk.bold( logger.fatal`Failed to connect to database ${chalk.bold(
// Index of the database in the array // Index of the database in the array
replicas.indexOf(dbPool) === -1 replicas.indexOf(dbPool) === -1
? "primary" ? "primary"
@ -63,17 +65,17 @@ export const setupDatabase = async (info = true): Promise<void> => {
} }
// Migrate the database // Migrate the database
info && databaseLogger.info`Migrating database...`; info && logger.info`Migrating database...`;
try { try {
await migrate(db, { await migrate(db, {
migrationsFolder: "./packages/plugin-kit/tables/migrations", migrationsFolder: "./packages/plugin-kit/tables/migrations",
}); });
} catch (e) { } catch (e) {
databaseLogger.fatal`Failed to migrate database. Please check your configuration.`; logger.fatal`Failed to migrate database. Please check your configuration.`;
throw e; throw e;
} }
info && databaseLogger.info`Database migrated`; info && logger.info`Database migrated`;
}; };

View file

@ -1,6 +1,7 @@
import process from "node:process"; import process from "node:process";
import { serverLogger } from "@versia-server/logging"; import { getLogger } from "@logtape/logtape";
import chalk from "chalk"; import chalk from "chalk";
import { sentry } from "@/sentry";
import { workers } from "./workers.ts"; import { workers } from "./workers.ts";
process.on("SIGINT", () => { process.on("SIGINT", () => {
@ -8,6 +9,9 @@ process.on("SIGINT", () => {
}); });
await import("./setup.ts"); await import("./setup.ts");
sentry?.captureMessage("Server started", "info");
const serverLogger = getLogger("server");
for (const [worker, fn] of Object.entries(workers)) { for (const [worker, fn] of Object.entries(workers)) {
serverLogger.info`Starting ${worker} Worker...`; serverLogger.info`Starting ${worker} Worker...`;

View file

@ -40,7 +40,7 @@
"dependencies": { "dependencies": {
"@versia-server/config": "workspace:*", "@versia-server/config": "workspace:*",
"@versia-server/kit": "workspace:*", "@versia-server/kit": "workspace:*",
"@versia-server/logging": "workspace:*", "chalk": "catalog:",
"chalk": "catalog:" "@logtape/logtape": "catalog:"
} }
} }

View file

@ -1,12 +1,17 @@
import { getLogger } from "@logtape/logtape";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { Note, setupDatabase } from "@versia-server/kit/db"; import { Note, setupDatabase } from "@versia-server/kit/db";
import { connection } from "@versia-server/kit/redis"; import { connection } from "@versia-server/kit/redis";
import { serverLogger } from "@versia-server/logging";
import chalk from "chalk"; import chalk from "chalk";
import { configureLoggers } from "@/loggers";
import { searchManager } from "../../classes/search/search-manager.ts"; import { searchManager } from "../../classes/search/search-manager.ts";
const timeAtStart = performance.now(); const timeAtStart = performance.now();
await configureLoggers();
const serverLogger = getLogger("server");
console.info(` console.info(`

144
utils/loggers.ts Normal file
View file

@ -0,0 +1,144 @@
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { getRotatingFileSink } from "@logtape/file";
import {
configure,
getConsoleSink,
getLevelFilter,
type LogLevel,
type LogRecord,
} from "@logtape/logtape";
import { config } from "@versia-server/config";
import chalk from "chalk";
// config.logging.log_file_path is a path to a file, create the directory if it doesn't exist
await mkdir(dirname(config.logging.log_file_path), { recursive: true });
const levelAbbreviations: Record<LogLevel, string> = {
debug: "DBG",
info: "INF",
warning: "WRN",
error: "ERR",
fatal: "FTL",
trace: "TRC",
};
/**
* The styles for the log level in the console.
*/
const logLevelStyles: Record<LogLevel, (text: string) => string> = {
debug: chalk.white.bgGray,
info: chalk.black.bgWhite,
warning: chalk.black.bgYellow,
error: chalk.white.bgRed,
fatal: chalk.white.bgRedBright,
trace: chalk.white.bgBlue,
};
/**
* The default console formatter.
*
* @param record The log record to format.
* @returns The formatted log record, as an array of arguments for
* {@link console.log}.
*/
export function defaultConsoleFormatter(record: LogRecord): string[] {
const msg = record.message.join("");
const date = new Date(record.timestamp);
const time = `${date.getUTCHours().toString().padStart(2, "0")}:${date
.getUTCMinutes()
.toString()
.padStart(
2,
"0",
)}:${date.getUTCSeconds().toString().padStart(2, "0")}.${date
.getUTCMilliseconds()
.toString()
.padStart(3, "0")}`;
const formattedTime = chalk.gray(time);
const formattedLevel = logLevelStyles[record.level](
levelAbbreviations[record.level],
);
const formattedCategory = chalk.gray(record.category.join("\xb7"));
const formattedMsg = chalk.reset(msg);
return [
`${formattedTime} ${formattedLevel} ${formattedCategory} ${formattedMsg}`,
];
}
export const configureLoggers = (silent = false): Promise<void> =>
configure({
reset: true,
sinks: {
console: getConsoleSink({
formatter: defaultConsoleFormatter,
}),
file: getRotatingFileSink(config.logging.log_file_path, {
maxFiles: 10,
maxSize: 10 * 1024 * 1024,
}),
},
filters: {
configFilter: silent
? getLevelFilter(null)
: getLevelFilter(config.logging.log_level),
},
loggers: [
{
category: "server",
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: ["federation", "inbox"],
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: ["federation", "delivery"],
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: ["federation", "bridge"],
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: ["federation", "resolvers"],
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: ["federation", "messaging"],
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: "database",
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: "webfinger",
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: "sonic",
sinks: ["console", "file"],
filters: ["configFilter"],
},
{
category: ["logtape", "meta"],
lowestLevel: "error",
},
{
category: "plugin",
sinks: ["console", "file"],
filters: ["configFilter"],
},
],
});

23
utils/sentry.ts Normal file
View file

@ -0,0 +1,23 @@
import * as Sentry from "@sentry/bun";
import { config } from "@versia-server/config";
import { env } from "bun";
import pkg from "~/package.json" with { type: "json" };
const sentryInstance =
config.logging.sentry &&
Sentry.init({
dsn: config.logging.sentry.dsn.origin,
debug: config.logging.sentry.debug,
sampleRate: config.logging.sentry.sample_rate,
maxBreadcrumbs: config.logging.sentry.max_breadcrumbs,
tracesSampleRate: config.logging.sentry.traces_sample_rate,
environment: config.logging.sentry.environment,
tracePropagationTargets:
config.logging.sentry.trace_propagation_targets,
release: env.GIT_COMMIT
? `${pkg.version}-${env.GIT_COMMIT}`
: pkg.version,
integrations: [Sentry.extraErrorDataIntegration()],
});
export const sentry = sentryInstance || undefined;

View file

@ -24,7 +24,9 @@ export const createServer = (
async fetch(req, server): Promise<Response> { async fetch(req, server): Promise<Response> {
const output = await app.fetch(req, { ip: server.requestIP(req) }); const output = await app.fetch(req, { ip: server.requestIP(req) });
await debugResponse(output.clone()); if (config.logging.types.responses) {
await debugResponse(output.clone());
}
return output; return output;
}, },