Logging in Bun often means choosing between "fast but dumb" or "smart but blocking".
@dws-std/logger gives you both: a type-safe, sink-based system that never blocks your main thread.
The goal is simple: Stop your logs from slowing down your app.
Most loggers either block on every write or lose type safety when you need structured logging. This package runs everything in a worker thread, batches automatically, and still gives you full TypeScript inference on what you log.
any everywhere.bun add @dws-std/logger
Create a logger, attach a sink, and start logging:
import { Logger, consoleSink } from '@dws-std/logger';
// Create a logger and register a console sink
const logger = new Logger().registerSink('console', consoleSink);
// Log messages (always pass an object)
logger.info({ message: 'Application started' });
logger.warn({ message: 'This is a warning' });
logger.error({ message: 'An error occurred', code: 500 });
logger.debug({ action: 'debug_info', data: { foo: 'bar' } });
logger.log({ event: 'generic_log' });
// Close the logger when done
await logger.close();
βΉοΈ Sinks are factory functions, not classes. You pass the factory itself (not the result of calling it) to
registerSink. The worker re-evaluates the factory string and calls it with thesinkArgsyou forwarded, so the sink is built inside the worker.
Need logs going to different places? Register as many sinks as you want:
import { Logger, consoleSink, fileSink } from '@dws-std/logger';
// Register multiple sinks
const logger = new Logger()
.registerSink('console', consoleSink)
.registerSink('file', fileSink, './app.log');
// Log to all sinks
logger.info({ message: 'This goes to console and file' });
// Log to specific sinks only
logger.error({ message: 'Only in file' }, ['file']);
logger.warn({ message: 'Only in console' }, ['console']);
await logger.close();
| Factory | Args | Description |
|---|---|---|
consoleSink |
none | Writes JSON log entries to console (routed by level). |
fileSink |
path |
Appends JSON log entries to a file via Bun.FileSink. |
devNullSink |
none | Discards everything β useful for benchmarks / dry runs. |
import { Logger, devNullSink, fileSink } from '@dws-std/logger';
const logger = new Logger()
.registerSink('applog', fileSink, './app.log')
.registerSink('silent', devNullSink);
A sink is a plain object implementing the LoggerSink interface β register a factory
function that builds and returns it. The factory is stringify-ed and re-evaluated inside the
worker, so its body must be self-contained: it may use its arguments, runtime globals
(Bun, console, JSON, β¦) and dynamic import(), but must not close over
module-scoped imports or variables from the calling file.
import { Logger, type SinkFactory } from '@dws-std/logger';
// A self-contained factory: no module-scoped imports captured.
const databaseSink: SinkFactory<{ query: string }, [dbUrl: string]> = (dbUrl: string) => {
// Open the connection inside the factory β the worker owns it.
const connection = /* β¦open using `dbUrl`β¦ */ {} as unknown;
return {
async log(level, timestamp, object) {
// object is typed as { query: string }
await (connection as { write: (s: string) => Promise<void> }).write(
JSON.stringify({ level, timestamp, object })
);
},
async close() {
await (connection as { close: () => Promise<void> }).close();
}
};
};
const logger = new Logger().registerSink('database', databaseSink, 'postgres://localhost/app');
logger.info({ query: 'SELECT 1' });
await logger.close();
LoggerSink interfaceinterface LoggerSink<TLogObject = unknown> {
log(level: LogLevels, timestamp: number, object: TLogObject): Promise<void> | void;
flush?(): Promise<void> | void; // called by Logger.flush()
close?(): Promise<void> | void; // called on Logger.close()
}
log is the only required method.flush is optional β implement it when your sink buffers writes and you want logger.flush() to push them through.close is optional β implement it to release file handles, connections, etc.When you define typed sinks, TypeScript knows exactly what shape your logs need. No more guessing, no more runtime surprises.
import { Logger, type LoggerSink, type LogLevels, type SinkFactory } from '@dws-std/logger';
interface UserLog {
userId: number;
action: string;
timestamp?: Date;
}
// Typed factory: the returned sink only accepts UserLog objects.
const userLogSink: SinkFactory<UserLog> = () => ({
log(level: LogLevels, timestamp: number, object: UserLog): void {
console.log(`User ${object.userId} performed: ${object.action}`);
}
});
const logger = new Logger().registerSink('userLog', userLogSink);
// β
TypeScript requires the correct shape
logger.info({ userId: 123, action: 'login' });
// β TypeScript error: Missing required property 'action'
logger.info({ userId: 123 });
When logging to multiple sinks at once, TypeScript creates an intersection of all the targeted sinks' types β you must satisfy every one of them.
interface UserLog {
userId: number;
action: string;
}
interface ApiLog {
endpoint: string;
method: string;
statusCode: number;
}
const userLogSink: SinkFactory<UserLog> = () => ({
async log(_level, _ts, object) {
// β¦ persist object β¦
void object;
}
});
const apiLogSink: SinkFactory<ApiLog> = () => ({
async log(_level, _ts, object) {
// β¦ persist object β¦
void object;
}
});
const logger = new Logger()
.registerSink('user', userLogSink)
.registerSink('api', apiLogSink);
// β
Logging to both sinks requires BOTH types combined
logger.info(
{
userId: 123,
action: 'api_call',
endpoint: '/users',
method: 'POST',
statusCode: 201
},
['user', 'api']
);
// β
Logging to only one sink requires only that sink's type
logger.warn({ userId: 456, action: 'failed_attempt' }, ['user']);
// β TypeScript error: Missing api properties
logger.error({ userId: 789, action: 'error' }, ['user', 'api']);
When you mix typed sinks with untyped ones (like consoleSink, which accepts unknown),
things stay flexible: the intersection with unknown lets extra properties through.
import { Logger, consoleSink, type SinkFactory } from '@dws-std/logger';
interface DatabaseLog {
query: string;
duration: number;
}
const databaseLogSink: SinkFactory<DatabaseLog> = () => ({
async log(_level, _ts, object) {
// β¦ persist object β¦
void object;
}
});
const logger = new Logger()
.registerSink('database', databaseLogSink)
.registerSink('console', consoleSink); // accepts unknown
// β
Works β the database type is enforced, console accepts anything
logger.info(
{
query: 'SELECT * FROM users',
duration: 123,
customData: 'anything goes'
},
['database', 'console']
);
Things break. When they do, you'll want to know:
import { Logger, consoleSink } from '@dws-std/logger';
const logger = new Logger().registerSink('console', consoleSink);
// Listen for sink errors (a sink throwing inside the worker)
logger.addListener('sinkError', (error) => {
console.error('Logger error:', error.message);
});
// Listen for sink registration errors (factory failed to build inside the worker)
logger.addListener('registerSinkError', (error) => {
console.error('Failed to register sink:', error.message);
});
logger.info({ message: 'Safe to log' });
await logger.close();
When you need to make sure everything is written before shutting down:
import { Logger, consoleSink } from '@dws-std/logger';
const logger = new Logger().registerSink('console', consoleSink);
logger.info({ message: 'First message' });
logger.info({ message: 'Second message' });
// Wait for all pending logs to be processed
await logger.flush();
// Close the logger and release resources (internally calls flush)
await logger.close();
flush() drains both the in-memory queue and each sink's own buffer (via the
optional flush() method on LoggerSink).
Fine-tune the batching and queue behavior:
import { Logger, consoleSink } from '@dws-std/logger';
const logger = new Logger({
maxPendingLogs: 10_000, // Max queued logs (default: 10,000)
batchSize: 100, // Logs per batch (default: 100)
batchTimeout: 0.1, // Ms before flushing a partial batch (default: 0.1)
maxMessagesInFlight: 100, // Max batches being processed (default: 100)
autoEnd: true, // Auto-close on process exit (default: true)
flushOnBeforeExit: true // Flush before exit (default: true)
}).registerSink('console', consoleSink);
Full docs: https://dominus-web-service.github.io/std/
MIT - Feel free to use it.