feat: Change logs a lil bit

This commit is contained in:
Wanjohi
2025-10-25 23:57:15 +03:00
parent 687bc47870
commit 815246afc0
10 changed files with 341 additions and 100 deletions

View File

@@ -90,9 +90,11 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"drizzle-orm": "^0.44.6", "drizzle-orm": "^0.44.6",
"hono": "catalog:",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"ulid": "^3.0.1", "ulid": "^3.0.1",
"xdg-basedir": "^5.1.0",
"zod": "catalog:", "zod": "catalog:",
"zod-openapi": "^5.4.3", "zod-openapi": "^5.4.3",
}, },
@@ -109,7 +111,7 @@
"name": "@nestri/function", "name": "@nestri/function",
"dependencies": { "dependencies": {
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"hono": "^4.10.1", "hono": "catalog:",
"hono-openapi": "^1.1.0", "hono-openapi": "^1.1.0",
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -249,6 +251,7 @@
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0", "@types/bun": "1.3.0",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"hono": "^4.10.1",
"steam-session": "1.9.3", "steam-session": "1.9.3",
"zod": "^4.1.12", "zod": "^4.1.12",
}, },
@@ -2939,6 +2942,8 @@
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],

View File

@@ -34,6 +34,7 @@
"@types/bun": "1.3.0", "@types/bun": "1.3.0",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"hono": "^4.10.1",
"steam-session": "1.9.3", "steam-session": "1.9.3",
"zod": "^4.1.12" "zod": "^4.1.12"
} }

View File

@@ -20,9 +20,11 @@
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "^0.44.6", "drizzle-orm": "^0.44.6",
"hono": "catalog:",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"ulid": "^3.0.1", "ulid": "^3.0.1",
"xdg-basedir": "^5.1.0",
"zod": "catalog:", "zod": "catalog:",
"zod-openapi": "^5.4.3" "zod-openapi": "^5.4.3"
} }

View File

@@ -1,4 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ContentfulStatusCode } from "hono/utils/http-status";
/** /**
* Standard error response schema used for OpenAPI documentation * Standard error response schema used for OpenAPI documentation
@@ -108,7 +109,7 @@ export class VisibleError extends Error {
/** /**
* Convert this error to an HTTP status code * Convert this error to an HTTP status code
*/ */
public statusCode(): number { public statusCode(): ContentfulStatusCode {
switch (this.type) { switch (this.type) {
case "validation": case "validation":
return 400; return 400;

View File

@@ -0,0 +1,50 @@
import fs from "fs/promises";
import path from "path";
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir";
const app = "nestri";
const data = path.join(xdgData!, app);
const cache = path.join(xdgCache!, app);
const config = path.join(xdgConfig!, app);
const state = path.join(xdgState!, app);
export namespace Global {
export const Path = {
data,
bin: path.join(data, "bin"),
log: path.join(data, "log"),
cache,
config,
state,
} as const;
}
await Promise.all([
fs.mkdir(Global.Path.data, { recursive: true }),
fs.mkdir(Global.Path.config, { recursive: true }),
fs.mkdir(Global.Path.state, { recursive: true }),
fs.mkdir(Global.Path.log, { recursive: true }),
fs.mkdir(Global.Path.bin, { recursive: true }),
]);
const CACHE_VERSION = "0";
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
.catch(() => "0");
if (version !== CACHE_VERSION) {
try {
const contents = await fs.readdir(Global.Path.cache);
await Promise.all(
contents.map((item) =>
fs.rm(path.join(Global.Path.cache, item), {
recursive: true,
force: true,
}),
),
);
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION);
}

View File

@@ -1,55 +1,192 @@
import z from "zod/v4";
import path from "path";
import fs from "fs/promises";
import { Context } from "../context.js"; import { Context } from "../context.js";
import { Global } from "../global/index.js";
export namespace Log { export namespace Log {
const ctx = Context.create<{ const ctx = Context.create<{
tags: Record<string, any>; tags: Record<string, any>;
}>(); }>();
export const Level = z
.enum(["DEBUG", "INFO", "WARN", "ERROR"])
.meta({ ref: "LogLevel", description: "Log level" });
export type Level = z.infer<typeof Level>;
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
let level: Level = "INFO";
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level];
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void;
info(message?: any, extra?: Record<string, any>): void;
error(message?: any, extra?: Record<string, any>): void;
warn(message?: any, extra?: Record<string, any>): void;
tag(key: string, value: string): Logger;
clone(): Logger;
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void;
[Symbol.dispose](): void;
};
};
const loggers = new Map<string, Logger>();
export const Default = create({ service: "default" });
export interface Options {
print: boolean;
dev?: boolean;
level?: Level;
}
let logpath = "";
export function file() {
return logpath;
}
export async function init(options: Options) {
if (options.level) level = options.level;
cleanup(Global.Path.log);
if (options.print) return;
logpath = path.join(
Global.Path.log,
options.dev
? "dev.log"
: `${new Date().toISOString().split(".")[0]?.replace(/:/g, "")}.log`,
);
const logfile = Bun.file(logpath);
await fs.truncate(logpath).catch(() => {});
const writer = logfile.writer();
process.stderr.write = (msg) => {
writer.write(msg);
writer.flush();
return true;
};
}
async function cleanup(dir: string) {
const glob = new Bun.Glob("????-??-??T??????.log");
const files = await Array.fromAsync(
glob.scan({
cwd: dir,
absolute: true,
}),
);
if (files.length <= 5) return;
const filesToDelete = files.slice(0, -10);
await Promise.all(
filesToDelete.map((file) => fs.unlink(file).catch(() => {})),
);
}
function formatError(error: Error, depth = 0): string {
const result = error.message;
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result;
}
let last = Date.now();
export function create(tags?: Record<string, any>) { export function create(tags?: Record<string, any>) {
tags = tags || {}; tags = tags || {};
const result = { const service = tags["service"];
info(msg: string, extra?: Record<string, any>) { if (service && typeof service === "string") {
const prefix = Object.entries({ const cached = loggers.get(service);
...use().tags, if (cached) {
...tags, return cached;
...extra, }
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...use().tags,
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`;
if (value instanceof Error) return prefix + formatError(value);
if (typeof value === "object") return prefix + JSON.stringify(value);
return prefix + value;
}) })
.map(([key, value]) => `${key}=${value}`) .join(" ");
.join(" "); const next = new Date();
console.log(prefix, msg); const diff = next.getTime() - last;
return result; last = next.getTime();
return (
[next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message]
.filter(Boolean)
.join(" ") + "\n"
);
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
process.stderr.write("DEBUG " + build(message, extra));
}
}, },
warn(msg: string, extra?: Record<string, any>) { info(message?: any, extra?: Record<string, any>) {
const prefix = Object.entries({ if (shouldLog("INFO")) {
...use().tags, process.stderr.write("INFO " + build(message, extra));
...tags, }
...extra,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ");
console.warn(prefix, msg);
return result;
}, },
error(error: Error) { error(message?: any, extra?: Record<string, any>) {
const prefix = Object.entries({ if (shouldLog("ERROR")) {
...use().tags, process.stderr.write("ERROR " + build(message, extra));
...tags, }
}) },
.map(([key, value]) => `${key}=${value}`) warn(message?: any, extra?: Record<string, any>) {
.join(" "); if (shouldLog("WARN")) {
console.error(prefix, error); process.stderr.write("WARN " + build(message, extra));
return result; }
}, },
tag(key: string, value: string) { tag(key: string, value: string) {
tags[key] = value; if (tags) tags[key] = value;
return result; return result;
}, },
clone() { clone() {
return Log.create({ ...tags }); return Log.create({ ...tags });
}, },
time(message: string, extra?: Record<string, any>) {
const now = Date.now();
result.info(message, { status: "started", ...extra });
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
});
}
return {
stop,
[Symbol.dispose]() {
stop();
},
};
},
}; };
if (service && typeof service === "string") {
loggers.set(service, result);
}
return result; return result;
} }

View File

@@ -13,7 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"hono": "^4.10.1", "hono": "catalog:",
"hono-openapi": "^1.1.0", "hono-openapi": "^1.1.0",
"zod": "catalog:" "zod": "catalog:"
} }

View File

@@ -1,69 +1,48 @@
import { subjects } from "../subjects"; import { Hono } from "hono";
import { MiddlewareHandler } from "hono"; import { auth } from "./utils/auth";
import { Actor } from "@nestri/core/actor"; import { logger } from "hono/logger";
import { Api } from "@nestri/core/api/index";
import { Log } from "@nestri/core/utils/log"; import { Log } from "@nestri/core/utils/log";
import { createClient } from "@openauthjs/openauth/client"; import { HTTPException } from "hono/http-exception";
import { VisibleError, ErrorCodes } from "@nestri/core/error"; import { VisibleError, ErrorCodes } from "@nestri/core/error";
const client = createClient({ const log = Log.create({ service: "api" });
clientID: "api",
issuer: process.env.AUTH_URL,
subjects,
});
const log = Log.create({ namespace: "api" }); export const app = new Hono();
app
.use(logger())
.use(async (c, next) => {
c.header("Cache-Control", "no-store");
return next();
})
.use(auth);
const auth: MiddlewareHandler = async (c, next) => { app.onError((error, c) => {
const authHeader = // Handle our custom VisibleError
c.req.query("authorization") ?? c.req.header("authorization"); if (error instanceof VisibleError) {
if (authHeader) { return c.json(error.toResponse(), error.statusCode());
const match = authHeader.match(/^Bearer (.+)$/);
if (!match || !match[1]) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Bearer token not found or improperly formatted",
);
}
const bearerToken = match[1];
if (bearerToken?.startsWith("nst_")) {
const token = await Api.Personal.fromToken(bearerToken);
if (!token)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid personal access token",
);
return Actor.provide(
"token",
{
teamID: token.teamID,
tokenID: token.id,
},
next,
);
}
const result = await client.verify(bearerToken!);
if (result.err)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid bearer token",
);
if (result.subject.type === "team") {
return Actor.provide(
"team",
{
email: result.subject.properties.email,
teamID: result.subject.properties.teamID,
},
next,
);
}
} }
return Actor.provide("public", {}, next); // Handle HTTP exceptions
}; if (error instanceof HTTPException) {
console.error("http error:", error);
return c.json(
{
type: "validation",
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: "Invalid request",
},
400,
);
}
// Handle any other errors as internal server errors
log.error("Unhandled API error:", { error });
return c.json(
{
type: "internal",
code: ErrorCodes.Server.INTERNAL_ERROR,
message: "Internal server error",
},
500,
);
});

View File

@@ -0,0 +1,67 @@
import { subjects } from "@/subjects";
import { MiddlewareHandler } from "hono";
import { Actor } from "@nestri/core/actor";
import { Api } from "@nestri/core/api/index";
import { createClient } from "@openauthjs/openauth/client";
import { VisibleError, ErrorCodes } from "@nestri/core/error";
const client = createClient({
clientID: "api",
issuer: process.env.AUTH_URL,
subjects,
});
export const auth: MiddlewareHandler = async (c, next) => {
const authHeader =
c.req.query("authorization") ?? c.req.header("authorization");
if (authHeader) {
const match = authHeader.match(/^Bearer (.+)$/);
if (!match || !match[1]) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Bearer token not found or improperly formatted",
);
}
const bearerToken = match[1];
if (bearerToken?.startsWith("nst_")) {
const token = await Api.Personal.fromToken(bearerToken);
if (!token)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid personal access token",
);
return Actor.provide(
"token",
{
teamID: token.teamID,
tokenID: token.id,
},
next,
);
}
const result = await client.verify(bearerToken!);
if (result.err)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid bearer token",
);
if (result.subject.type === "team") {
return Actor.provide(
"team",
{
email: result.subject.properties.email,
teamID: result.subject.properties.teamID,
},
next,
);
}
}
return Actor.provide("public", {}, next);
};

View File

@@ -1,6 +1,7 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { RedisClient } from "bun"; import { RedisClient } from "bun";
import { subjects } from "../subjects.js"; import { logger } from "hono/logger";
import { subjects } from "@/subjects.js";
import { issuer } from "@openauthjs/openauth"; import { issuer } from "@openauthjs/openauth";
import { Team } from "@nestri/core/team/index"; import { Team } from "@nestri/core/team/index";
import { RedisStorage } from "./src/storage.js"; import { RedisStorage } from "./src/storage.js";
@@ -62,12 +63,10 @@ const app = issuer({
async allow(input) { async allow(input) {
const url = new URL(input.redirectURI); const url = new URL(input.redirectURI);
return ( return (
url.hostname.endsWith("localhost") || url.hostname.endsWith("localhost") || url.hostname.endsWith("nestri.io")
url.hostname.endsWith("nestri.io") ||
url.hostname.endsWith("console.nestri.io")
); );
}, },
}); }).use(logger());
export default { export default {
port: 3000, port: 3000,