Initial boilerplate scaffold for continuum-common
Adds ts-core (logger, Supabase JWT verification, Elysia error transformers) and rust-core (thiserror error type, tracing init, G-code checksum/header parsing) packages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# continuum-common is a library repo (ts-core / rust-core) and has no runtime
|
||||||
|
# of its own. These vars are only needed when running this package's tests,
|
||||||
|
# which exercise the Supabase JWT verification helper against real settings.
|
||||||
|
|
||||||
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_JWT_SECRET=your-supabase-jwt-secret
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Node / Bun
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
bun.lockb
|
||||||
|
.bun/
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# continuum-common
|
||||||
|
|
||||||
|
Shared, dependency-light building blocks used across the Continuum cloud and edge services.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- **`packages/ts-core`** — TypeScript package consumed by `continuum-backend` (and other Bun/Node services). Provides a structured logger, a Supabase JWT verification helper, and ElysiaJS-compatible HTTP error transformers.
|
||||||
|
- **`packages/rust-core`** — Rust crate consumed by `continuum-proxy` and `continuum-ai-worker`. Provides a shared `thiserror`-based error type, `tracing` initialization, and G-code checksum/header parsing helpers.
|
||||||
|
|
||||||
|
Each package is versioned and published independently; see their own READMEs (inline doc comments) for usage.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "continuum-common",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/ts-core"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun run --filter '*' build",
|
||||||
|
"typecheck": "bun run --filter '*' typecheck"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "continuum-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Shared error handling, tracing setup, and G-code helpers for Continuum Rust services"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("invalid g-code checksum on line: {line}")]
|
||||||
|
GcodeChecksum { line: String },
|
||||||
|
|
||||||
|
#[error("malformed g-code header: {reason}")]
|
||||||
|
GcodeHeader { reason: String },
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
use crate::error::{Error, Result};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// XORs every byte in `data` together, per the standard Marlin/RepRap
|
||||||
|
/// G-code checksum scheme (checksum covers everything before the `*`,
|
||||||
|
/// including any leading `N<n> ` line number).
|
||||||
|
pub fn compute_checksum(data: &[u8]) -> u8 {
|
||||||
|
data.iter().fold(0u8, |acc, &b| acc ^ b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a line into its content and trailing `*NN` checksum, if present.
|
||||||
|
pub fn split_checksum(line: &str) -> (&str, Option<u8>) {
|
||||||
|
match line.rsplit_once('*') {
|
||||||
|
Some((content, checksum_str)) => match checksum_str.trim().parse::<u8>() {
|
||||||
|
Ok(value) => (content, Some(value)),
|
||||||
|
Err(_) => (line, None),
|
||||||
|
},
|
||||||
|
None => (line, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a checksummed G-code line (e.g. `N12 G1 X10 Y20*43`).
|
||||||
|
/// Returns `Err` if the line carries no `*NN` suffix to verify against.
|
||||||
|
pub fn verify_line_checksum(line: &str) -> Result<bool> {
|
||||||
|
let (content, checksum) = split_checksum(line);
|
||||||
|
match checksum {
|
||||||
|
Some(expected) => Ok(compute_checksum(content.as_bytes()) == expected),
|
||||||
|
None => Err(Error::GcodeChecksum {
|
||||||
|
line: line.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a computed `*NN` checksum to a line that doesn't have one yet.
|
||||||
|
pub fn append_checksum(line: &str) -> String {
|
||||||
|
format!("{line}*{}", compute_checksum(line.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GcodeHeader {
|
||||||
|
pub slicer: Option<String>,
|
||||||
|
pub filament_type: Option<String>,
|
||||||
|
pub nozzle_diameter: Option<String>,
|
||||||
|
pub estimated_print_time_secs: Option<u64>,
|
||||||
|
/// Every `; key = value` comment field found, keyed by `key`.
|
||||||
|
pub raw_fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses slicer metadata out of a G-code file's `;`-prefixed comment lines.
|
||||||
|
/// Handles the PrusaSlicer/SuperSlicer/BambuStudio `; key = value` config
|
||||||
|
/// block as well as Cura's `;KEY:value` header and both slicers' free-form
|
||||||
|
/// `generated by` / estimated-time comments.
|
||||||
|
pub fn parse_header(gcode: &str) -> GcodeHeader {
|
||||||
|
let mut header = GcodeHeader::default();
|
||||||
|
|
||||||
|
for raw_line in gcode.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
let comment = match line.strip_prefix(';') {
|
||||||
|
Some(rest) => rest.trim(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if comment.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.slicer.is_none() {
|
||||||
|
if let Some(rest) = comment
|
||||||
|
.strip_prefix("generated by ")
|
||||||
|
.or_else(|| comment.strip_prefix("Generated by "))
|
||||||
|
{
|
||||||
|
let name = rest.split(" on ").next().unwrap_or(rest).trim();
|
||||||
|
header.slicer = Some(name.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(rest) = comment
|
||||||
|
.strip_prefix("Generated with ")
|
||||||
|
.or_else(|| comment.strip_prefix("generated with "))
|
||||||
|
{
|
||||||
|
header.slicer = Some(rest.trim().to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((key, value)) = comment.split_once('=') {
|
||||||
|
let key = key.trim().to_string();
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
if !key.is_empty() && !value.is_empty() {
|
||||||
|
match key.as_str() {
|
||||||
|
"filament_type" => header.filament_type = Some(value.clone()),
|
||||||
|
"nozzle_diameter" => header.nozzle_diameter = Some(value.clone()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if key.starts_with("estimated printing time") {
|
||||||
|
header.estimated_print_time_secs = parse_duration(&value);
|
||||||
|
}
|
||||||
|
header.raw_fields.insert(key, value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((key, value)) = comment.split_once(':') {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
if key.eq_ignore_ascii_case("TIME") {
|
||||||
|
header.estimated_print_time_secs =
|
||||||
|
value.parse::<u64>().ok().or_else(|| parse_duration(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses slicer duration strings like `1h 23m 45s` or `45m 2s` into seconds.
|
||||||
|
fn parse_duration(s: &str) -> Option<u64> {
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
let mut digits = String::new();
|
||||||
|
let mut matched_any = false;
|
||||||
|
|
||||||
|
for c in s.chars() {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
digits.push(c);
|
||||||
|
} else if matches!(c, 'd' | 'h' | 'm' | 's') {
|
||||||
|
if digits.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n: u64 = digits.parse().ok()?;
|
||||||
|
digits.clear();
|
||||||
|
matched_any = true;
|
||||||
|
total += match c {
|
||||||
|
'd' => n * 86_400,
|
||||||
|
'h' => n * 3_600,
|
||||||
|
'm' => n * 60,
|
||||||
|
's' => n,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
digits.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched_any.then_some(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checksum_roundtrip() {
|
||||||
|
let line = "N12 G1 X10.0 Y20.0 F1500.0";
|
||||||
|
let with_checksum = append_checksum(line);
|
||||||
|
assert!(verify_line_checksum(&with_checksum).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checksum_detects_corruption() {
|
||||||
|
let good = append_checksum("N1 G28");
|
||||||
|
let corrupted = good.replace("G28", "G29");
|
||||||
|
assert!(!verify_line_checksum(&corrupted).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_parses_prusaslicer_style() {
|
||||||
|
let gcode = "\
|
||||||
|
; generated by PrusaSlicer 2.7.0+win64 on 2026-01-01 at 12:00:00
|
||||||
|
; filament_type = PLA
|
||||||
|
; nozzle_diameter = 0.4
|
||||||
|
; estimated printing time (normal mode) = 1h 23m 45s
|
||||||
|
G28
|
||||||
|
";
|
||||||
|
let header = parse_header(gcode);
|
||||||
|
assert_eq!(header.slicer.as_deref(), Some("PrusaSlicer 2.7.0+win64"));
|
||||||
|
assert_eq!(header.filament_type.as_deref(), Some("PLA"));
|
||||||
|
assert_eq!(header.estimated_print_time_secs, Some(5025));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_parses_cura_style() {
|
||||||
|
let gcode = ";Generated with Cura_SteamEngine 5.6.0\n;TIME:3725\nG28\n";
|
||||||
|
let header = parse_header(gcode);
|
||||||
|
assert_eq!(header.slicer.as_deref(), Some("Cura_SteamEngine 5.6.0"));
|
||||||
|
assert_eq!(header.estimated_print_time_secs, Some(3725));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod error;
|
||||||
|
pub mod gcode;
|
||||||
|
pub mod tracing_init;
|
||||||
|
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
pub use tracing_init::init_tracing;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
|
/// Initializes a JSON-formatted `tracing` subscriber driven by `RUST_LOG`
|
||||||
|
/// (defaulting to `info`). Call once at process startup.
|
||||||
|
pub fn init_tracing(service_name: &str) {
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(fmt::layer().json().with_target(true))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!(service = service_name, "tracing initialized");
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@continuum/ts-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^5.9.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"elysia": ">=1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"elysia": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.1.14",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"elysia": "^1.1.26"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
|
||||||
|
export interface SupabaseClaims extends JWTPayload {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
aud: string | string[];
|
||||||
|
app_metadata?: Record<string, unknown>;
|
||||||
|
user_metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JwtVerificationError extends Error {
|
||||||
|
constructor(message: string, readonly cause?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = "JwtVerificationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifierOptions {
|
||||||
|
/** Supabase project URL, e.g. https://xyzcompany.supabase.co */
|
||||||
|
supabaseUrl: string;
|
||||||
|
/**
|
||||||
|
* Legacy HS256 project JWT secret. When provided, verification uses this
|
||||||
|
* shared secret instead of fetching the project's JWKS. Prefer leaving
|
||||||
|
* this unset for projects on Supabase's newer asymmetric (ES256/RS256)
|
||||||
|
* signing keys.
|
||||||
|
*/
|
||||||
|
jwtSecret?: string;
|
||||||
|
audience?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupabaseJwtVerifier {
|
||||||
|
verify(token: string): Promise<SupabaseClaims>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSupabaseJwtVerifier(options: VerifierOptions): SupabaseJwtVerifier {
|
||||||
|
const audience = options.audience ?? "authenticated";
|
||||||
|
|
||||||
|
if (options.jwtSecret) {
|
||||||
|
const key = new TextEncoder().encode(options.jwtSecret);
|
||||||
|
return {
|
||||||
|
async verify(token: string): Promise<SupabaseClaims> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, key, {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
audience,
|
||||||
|
});
|
||||||
|
return payload as SupabaseClaims;
|
||||||
|
} catch (err) {
|
||||||
|
throw new JwtVerificationError("failed to verify Supabase JWT (HS256)", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", options.supabaseUrl);
|
||||||
|
const jwks = createRemoteJWKSet(jwksUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async verify(token: string): Promise<SupabaseClaims> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, jwks, { audience });
|
||||||
|
return payload as SupabaseClaims;
|
||||||
|
} catch (err) {
|
||||||
|
throw new JwtVerificationError("failed to verify Supabase JWT (JWKS)", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBearerToken(authorizationHeader: string | null | undefined): string {
|
||||||
|
if (!authorizationHeader) {
|
||||||
|
throw new JwtVerificationError("missing Authorization header");
|
||||||
|
}
|
||||||
|
const [scheme, token] = authorizationHeader.split(" ");
|
||||||
|
if (scheme !== "Bearer" || !token) {
|
||||||
|
throw new JwtVerificationError("Authorization header is not a Bearer token");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export abstract class HttpError extends Error {
|
||||||
|
abstract readonly status: number;
|
||||||
|
abstract readonly code: string;
|
||||||
|
|
||||||
|
constructor(message: string, readonly details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = new.target.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: this.code,
|
||||||
|
message: this.message,
|
||||||
|
details: this.details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BadRequestError extends HttpError {
|
||||||
|
readonly status = 400;
|
||||||
|
readonly code = "BAD_REQUEST";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends HttpError {
|
||||||
|
readonly status = 401;
|
||||||
|
readonly code = "UNAUTHORIZED";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends HttpError {
|
||||||
|
readonly status = 403;
|
||||||
|
readonly code = "FORBIDDEN";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends HttpError {
|
||||||
|
readonly status = 404;
|
||||||
|
readonly code = "NOT_FOUND";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends HttpError {
|
||||||
|
readonly status = 409;
|
||||||
|
readonly code = "CONFLICT";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InternalError extends HttpError {
|
||||||
|
readonly status = 500;
|
||||||
|
readonly code = "INTERNAL_ERROR";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElysiaErrorContext {
|
||||||
|
code: string | number;
|
||||||
|
error: unknown;
|
||||||
|
set: { status?: number | string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in handler for Elysia's `.onError()`. Maps our HttpError hierarchy,
|
||||||
|
* Elysia's built-in error codes, and anything unrecognized to a consistent
|
||||||
|
* `{ error: { code, message, details? } }` JSON body.
|
||||||
|
*/
|
||||||
|
export function toErrorResponse({ code, error, set }: ElysiaErrorContext) {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
set.status = error.status;
|
||||||
|
return error.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "VALIDATION") {
|
||||||
|
set.status = 422;
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION",
|
||||||
|
message: error instanceof Error ? error.message : "validation failed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "NOT_FOUND") {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: { code: "NOT_FOUND", message: "not found" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message: error instanceof Error ? error.message : "internal server error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./logger";
|
||||||
|
export * from "./auth";
|
||||||
|
export * from "./errors";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
const LEVEL_WEIGHT: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LoggerOptions {
|
||||||
|
name: string;
|
||||||
|
level?: LogLevel;
|
||||||
|
base?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
debug(msg: string, fields?: Record<string, unknown>): void;
|
||||||
|
info(msg: string, fields?: Record<string, unknown>): void;
|
||||||
|
warn(msg: string, fields?: Record<string, unknown>): void;
|
||||||
|
error(msg: string, fields?: Record<string, unknown>): void;
|
||||||
|
child(fields: Record<string, unknown>): Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(
|
||||||
|
level: LogLevel,
|
||||||
|
minLevel: LogLevel,
|
||||||
|
name: string,
|
||||||
|
base: Record<string, unknown>,
|
||||||
|
msg: string,
|
||||||
|
fields?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[minLevel]) return;
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
name,
|
||||||
|
msg,
|
||||||
|
...base,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
const line = JSON.stringify(record);
|
||||||
|
if (level === "error") {
|
||||||
|
console.error(line);
|
||||||
|
} else if (level === "warn") {
|
||||||
|
console.warn(line);
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(options: LoggerOptions): Logger {
|
||||||
|
const level = options.level ?? (process.env.LOG_LEVEL as LogLevel | undefined) ?? "info";
|
||||||
|
const base = options.base ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
debug: (msg, fields) => write("debug", level, options.name, base, msg, fields),
|
||||||
|
info: (msg, fields) => write("info", level, options.name, base, msg, fields),
|
||||||
|
warn: (msg, fields) => write("warn", level, options.name, base, msg, fields),
|
||||||
|
error: (msg, fields) => write("error", level, options.name, base, msg, fields),
|
||||||
|
child: (fields) =>
|
||||||
|
createLogger({ name: options.name, level, base: { ...base, ...fields } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user