Skip to content

Commit 6032b66

Browse files
authored
Merge pull request #71 from itzmanish/feat/logger
Logger implementation
2 parents def2fdd + 7787987 commit 6032b66

29 files changed

Lines changed: 1005 additions & 447 deletions

lib/common/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,12 @@
11
export { asError } from "./error"
2+
export {
3+
setGlobalLogger,
4+
getGlobalLogger,
5+
createConsoleLogger,
6+
getLogger,
7+
notifyLoggerLevelChanged,
8+
onLoggerLevelChange,
9+
installWorkerLogReceiver,
10+
installWorkletLogReceiver,
11+
} from "./logger"
12+
export type { Logger, LogLevel, LogLevelName, ScopedLogger, WorkerLogRecord } from "./logger"

lib/common/logger.ts

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
/**
2+
* MoQJS Logger
3+
*
4+
* Design:
5+
* - One global dispatcher lives in this module.
6+
* - `getLogger()` is called once per module with no arguments. It reads the
7+
* caller's file path from the call stack to derive a scope string
8+
* (e.g. "transport/connection"), then returns a tiny frozen object whose
9+
* five methods close over that scope string and call the single dispatcher.
10+
* - Only one dispatcher function set exists in memory. Per-module objects are
11+
* five arrow-function references + one string — negligible and unavoidable.
12+
*
13+
* Usage inside the library (top of every .ts file):
14+
* import { getLogger } from "../common/logger"
15+
* const log = getLogger()
16+
* log.debug("starting control loop")
17+
*
18+
* Usage as a library consumer:
19+
* import { setGlobalLogger, createConsoleLogger } from "@moq-js/player"
20+
* setGlobalLogger(createConsoleLogger("debug"))
21+
* setGlobalLogger({ level: () => "warn", warn: console.warn, error: console.error })
22+
* setGlobalLogger(undefined) // restore default (error-only)
23+
*
24+
* Every emit forwards: "[MoQJS]", "[<scope>]", ...args
25+
*/
26+
27+
// ---------------------------------------------------------------------------
28+
// Public types
29+
// ---------------------------------------------------------------------------
30+
31+
/**
32+
* Log level for controlling MoQJS output verbosity.
33+
*
34+
* `"*"` — enable everything (same as "trace")
35+
* `"none"` — suppress all MoQJS output
36+
*/
37+
export type LogLevel = "*" | "trace" | "debug" | "info" | "warn" | "error" | "none"
38+
39+
/**
40+
* Logger interface implemented by consumers to receive MoQJS log messages.
41+
*
42+
* The library calls `level()` before every emit. If `level` is absent it
43+
* defaults to `"error"` and emits one `console.warn` exactly once.
44+
*
45+
* All methods receive leading args: `"[MoQJS]"`, `"[<scope>]"`, ...original args.
46+
* Individual level methods are optional — silently skipped when absent.
47+
*/
48+
export interface Logger {
49+
level?(): LogLevel
50+
trace?(...args: unknown[]): void
51+
debug?(...args: unknown[]): void
52+
info?(...args: unknown[]): void
53+
warn?(...args: unknown[]): void
54+
error?(...args: unknown[]): void
55+
}
56+
57+
/** The scoped logger returned to each module by `getLogger()`. */
58+
export interface ScopedLogger {
59+
readonly scope: string
60+
trace(...args: unknown[]): void
61+
debug(...args: unknown[]): void
62+
info(...args: unknown[]): void
63+
warn(...args: unknown[]): void
64+
error(...args: unknown[]): void
65+
}
66+
67+
/** Concrete level names that appear in log records (no meta-levels). */
68+
export type LogLevelName = "trace" | "debug" | "info" | "warn" | "error"
69+
70+
/** A log record forwarded from a Web Worker or AudioWorklet to the main thread. */
71+
export interface WorkerLogRecord {
72+
level: LogLevelName
73+
args: unknown[]
74+
}
75+
76+
// ---------------------------------------------------------------------------
77+
// Level ordering
78+
// ---------------------------------------------------------------------------
79+
80+
const LEVEL_ORDER: Record<LogLevel, number> = {
81+
"*": 0,
82+
trace: 0,
83+
debug: 1,
84+
info: 2,
85+
warn: 3,
86+
error: 4,
87+
none: 5,
88+
}
89+
90+
const METHOD_ORDER: Record<LogLevelName, number> = {
91+
trace: 0,
92+
debug: 1,
93+
info: 2,
94+
warn: 3,
95+
error: 4,
96+
}
97+
98+
function _isEnabled(configured: LogLevel, emitting: LogLevelName): boolean {
99+
return METHOD_ORDER[emitting] >= LEVEL_ORDER[configured]
100+
}
101+
102+
// ---------------------------------------------------------------------------
103+
// Global logger state
104+
// ---------------------------------------------------------------------------
105+
106+
let _globalLogger: Logger = createConsoleLogger("error")
107+
let _missingLevelWarned = false
108+
let _levelListeners: Array<(level: LogLevel) => void> = []
109+
110+
/** Install a custom logger. Pass `undefined` to restore the default (error level). */
111+
export function setGlobalLogger(logger: Logger | undefined): void {
112+
_missingLevelWarned = false
113+
_globalLogger = logger ?? createConsoleLogger("error")
114+
_fireLevelListeners()
115+
}
116+
117+
/** Returns the currently installed global logger. */
118+
export function getGlobalLogger(): Logger {
119+
return _globalLogger
120+
}
121+
122+
/**
123+
* Call this when your custom logger's `level()` return value changes at
124+
* runtime. Notifies internal infrastructure (e.g. workers) to re-sync.
125+
*/
126+
export function notifyLoggerLevelChanged(): void {
127+
_fireLevelListeners()
128+
}
129+
130+
/** @internal */
131+
export function onLoggerLevelChange(cb: (level: LogLevel) => void): () => void {
132+
_levelListeners.push(cb)
133+
return () => {
134+
_levelListeners = _levelListeners.filter((l) => l !== cb)
135+
}
136+
}
137+
138+
function _fireLevelListeners(): void {
139+
const level = _resolveLevel()
140+
for (const cb of _levelListeners) cb(level)
141+
}
142+
143+
function _resolveLevel(): LogLevel {
144+
const logger = _globalLogger
145+
if (typeof logger.level !== "function") {
146+
if (!_missingLevelWarned) {
147+
_missingLevelWarned = true
148+
console.warn('[MoQJS] custom logger missing level(); defaulting to "error"')
149+
}
150+
return "error"
151+
}
152+
return logger.level()
153+
}
154+
155+
// ---------------------------------------------------------------------------
156+
// Built-in console logger factory
157+
// ---------------------------------------------------------------------------
158+
159+
/**
160+
* Creates a Logger backed by the browser/Node console.
161+
* @example
162+
* setGlobalLogger(createConsoleLogger("debug"))
163+
* setGlobalLogger(createConsoleLogger("none")) // silence all
164+
*/
165+
export function createConsoleLogger(level: LogLevel = "error"): Logger {
166+
return {
167+
level: () => level,
168+
trace: (...args: unknown[]) => {
169+
console.debug(...args)
170+
},
171+
debug: (...args: unknown[]) => {
172+
console.debug(...args)
173+
},
174+
info: (...args: unknown[]) => {
175+
console.info(...args)
176+
},
177+
warn: (...args: unknown[]) => {
178+
console.warn(...args)
179+
},
180+
error: (...args: unknown[]) => {
181+
console.error(...args)
182+
},
183+
}
184+
}
185+
186+
// ---------------------------------------------------------------------------
187+
// Core dispatcher — the only place that touches _globalLogger per emit
188+
// ---------------------------------------------------------------------------
189+
190+
function _dispatch(level: LogLevelName, scope: string, args: unknown[]): void {
191+
const logger = _globalLogger
192+
if (!_isEnabled(_resolveLevel(), level)) return
193+
const method = logger[level]
194+
if (typeof method === "function") {
195+
method.call(logger, "[MoQJS]", `[${scope}]`, ...args)
196+
}
197+
}
198+
199+
// ---------------------------------------------------------------------------
200+
// Scope derivation — called once per module at import time
201+
// ---------------------------------------------------------------------------
202+
203+
/**
204+
* Derives a scope string from a file URL or path.
205+
* "file:///…/lib/transport/connection.ts" → "transport/connection"
206+
* "/abs/path/lib/playback/index.ts" → "playback"
207+
* Strips "index" suffix so directory modules look clean.
208+
*/
209+
function _scopeFromUrl(url: string): string {
210+
const path = url
211+
.replace(/^file:\/\//, "")
212+
.split("?")[0]
213+
.split("#")[0]
214+
215+
const libIdx = path.lastIndexOf("/lib/")
216+
const relative = libIdx >= 0 ? path.slice(libIdx + 5) : path
217+
218+
const noExt = relative.replace(/\.[tj]s$/, "")
219+
const noIndex = noExt.replace(/\/index$/, "")
220+
221+
return noIndex || relative
222+
}
223+
224+
/**
225+
* Reads the call stack to find the URL of the caller's module.
226+
* Skips frames belonging to this file.
227+
*/
228+
function _callerUrl(): string {
229+
const stack = new Error().stack ?? ""
230+
const lines = stack.split("\n")
231+
for (const line of lines) {
232+
const match = line.match(/\(?([^\s()]+\.[tj]s)[^)]*\)?$/)
233+
if (!match) continue
234+
const url = match[1]
235+
if (url.includes("common/logger")) continue
236+
return url
237+
}
238+
return ""
239+
}
240+
241+
// ---------------------------------------------------------------------------
242+
// getLogger — the only public API modules need to call
243+
// ---------------------------------------------------------------------------
244+
245+
/**
246+
* Returns a `ScopedLogger` for the calling module. Call once at module level:
247+
*
248+
* const log = getLogger()
249+
*
250+
* Scope is derived automatically from the caller's file path.
251+
*/
252+
export function getLogger(explicitScope?: string): ScopedLogger {
253+
const scope = explicitScope ?? _scopeFromUrl(_callerUrl())
254+
255+
return Object.freeze({
256+
scope,
257+
trace: (...args: unknown[]) => _dispatch("trace", scope, args),
258+
debug: (...args: unknown[]) => _dispatch("debug", scope, args),
259+
info: (...args: unknown[]) => _dispatch("info", scope, args),
260+
warn: (...args: unknown[]) => _dispatch("warn", scope, args),
261+
error: (...args: unknown[]) => _dispatch("error", scope, args),
262+
})
263+
}
264+
265+
// ---------------------------------------------------------------------------
266+
// Worker-side logger
267+
// ---------------------------------------------------------------------------
268+
269+
let _workerLogLevel: LogLevel = "error"
270+
271+
/** Called by the worker's message handler when a logLevel message arrives. */
272+
export function setWorkerLogLevel(level: LogLevel): void {
273+
_workerLogLevel = level
274+
}
275+
276+
/**
277+
* Returns a ScopedLogger for use inside a Web Worker. Scope is derived from
278+
* the caller's file path automatically. Log records are forwarded to the main
279+
* thread via `postMessage`.
280+
*
281+
* const log = getWorkerLogger()
282+
*/
283+
export function getWorkerLogger(explicitScope?: string): ScopedLogger {
284+
const scope = explicitScope ?? _scopeFromUrl(_callerUrl())
285+
286+
function _workerDispatch(level: LogLevelName, args: unknown[]): void {
287+
if (!_isEnabled(_workerLogLevel, level)) return
288+
const record: WorkerLogRecord = { level, args: ["[MoQJS]", `[${scope}]`, ...args] }
289+
;(self as unknown as { postMessage(msg: unknown): void }).postMessage({ log: record })
290+
}
291+
292+
return Object.freeze({
293+
scope,
294+
trace: (...args: unknown[]) => _workerDispatch("trace", args),
295+
debug: (...args: unknown[]) => _workerDispatch("debug", args),
296+
info: (...args: unknown[]) => _workerDispatch("info", args),
297+
warn: (...args: unknown[]) => _workerDispatch("warn", args),
298+
error: (...args: unknown[]) => _workerDispatch("error", args),
299+
})
300+
}
301+
302+
// ---------------------------------------------------------------------------
303+
// Worklet-side logger
304+
// ---------------------------------------------------------------------------
305+
306+
let _workletLogLevel: LogLevel = "error"
307+
308+
/** Called by the worklet's message handler when a logLevel message arrives. */
309+
export function setWorkletLogLevel(level: LogLevel): void {
310+
_workletLogLevel = level
311+
}
312+
313+
/**
314+
* Returns a ScopedLogger for use inside an AudioWorklet processor. Scope is
315+
* derived automatically. Log records are forwarded through the worklet port.
316+
*
317+
* const log = getWorkletLogger(this.port)
318+
*/
319+
export function getWorkletLogger(port: MessagePort, explicitScope?: string): ScopedLogger {
320+
const scope = explicitScope ?? _scopeFromUrl(_callerUrl())
321+
322+
function _workletDispatch(level: LogLevelName, args: unknown[]): void {
323+
if (!_isEnabled(_workletLogLevel, level)) return
324+
const record: WorkerLogRecord = { level, args: ["[MoQJS]", `[${scope}]`, ...args] }
325+
port.postMessage({ log: record })
326+
}
327+
328+
return Object.freeze({
329+
scope,
330+
trace: (...args: unknown[]) => _workletDispatch("trace", args),
331+
debug: (...args: unknown[]) => _workletDispatch("debug", args),
332+
info: (...args: unknown[]) => _workletDispatch("info", args),
333+
warn: (...args: unknown[]) => _workletDispatch("warn", args),
334+
error: (...args: unknown[]) => _workletDispatch("error", args),
335+
})
336+
}
337+
338+
// ---------------------------------------------------------------------------
339+
// Main-thread receivers
340+
// ---------------------------------------------------------------------------
341+
342+
/**
343+
* Listens for `{ log: WorkerLogRecord }` messages from a Worker and routes
344+
* them into the global logger. Returns a dispose function.
345+
* @internal
346+
*/
347+
export function installWorkerLogReceiver(worker: Worker): () => void {
348+
const handler = (e: MessageEvent) => {
349+
const msg = e.data as { log?: WorkerLogRecord }
350+
if (msg?.log) _dispatchRecord(msg.log)
351+
}
352+
worker.addEventListener("message", handler)
353+
return () => worker.removeEventListener("message", handler)
354+
}
355+
356+
/**
357+
* Listens for `{ log: WorkerLogRecord }` messages from an AudioWorklet port
358+
* and routes them into the global logger. Returns a dispose function.
359+
* @internal
360+
*/
361+
export function installWorkletLogReceiver(port: MessagePort): () => void {
362+
const handler = (e: MessageEvent) => {
363+
const msg = e.data as { log?: WorkerLogRecord }
364+
if (msg?.log) _dispatchRecord(msg.log)
365+
}
366+
port.addEventListener("message", handler)
367+
return () => port.removeEventListener("message", handler)
368+
}
369+
370+
function _dispatchRecord({ level, args }: WorkerLogRecord): void {
371+
const logger = _globalLogger
372+
if (!_isEnabled(_resolveLevel(), level)) return
373+
const method = logger[level]
374+
if (typeof method === "function") method.call(logger, ...args)
375+
}

0 commit comments

Comments
 (0)