Last active
January 17, 2017 04:17
-
-
Save axefrog/3a0494b196fd596bad5ebbc47e95dcb0 to your computer and use it in GitHub Desktop.
Full-featured console-based log/trace debugger for most.js, borrowing ideas from CQRS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Immutable from 'immutable'; | |
import formatDate from 'date-fns/format'; | |
import immutableDiff from 'immutablediff'; | |
/* eslint-disable no-underscore-dangle */ | |
var colors = { | |
0: ['white', '#7fbad8', '#0075b2'], | |
1: ['white', '#91a0ce', '#24429e'], | |
2: ['white', '#ab86e0', '#570ec1'], | |
3: ['white', '#c693cb', '#8d2798'], | |
4: ['white', '#e17fa2', '#c30045'], | |
5: ['white', '#ee8c7f', '#de1900'], | |
6: ['white', '#eeb27f', '#de6500'], | |
7: ['black', '#6f4900', '#de9200'], | |
8: ['black', '#6f5f00', '#debe00'], | |
9: ['black', '#6c7200', '#d9e400'], | |
10: ['white', '#b8e08d', '#72c11b'], | |
11: ['white', '#94d4a9', '#2aaa54'], | |
12: ['black', '#797a7a', '#f2f4f4'], | |
13: ['black', '#333339', '#676773'], | |
main: i => colors[i][2], | |
inverse: i => colors[i][0], | |
mid: i => colors[i][1], | |
}; | |
function colorText(i, mid = false) { | |
return `color: ${mid ? colors.mid(i) : colors.main(i)}`; | |
} | |
function colorFill(i, mid = false) { | |
return `color: ${mid ? colors.mid(i) : colors.inverse(i)}; background-color: ${colors.main(i)}`; | |
} | |
function colorFillInv(i, mid = false) { | |
return `color: ${mid ? colors.mid(i) : colors.main(i)}; background-color: ${colors.inverse(i)}`; | |
} | |
const dimTextStyle = colorText(13); | |
const colorsByEventType = { | |
construct: 13, | |
run: 11, | |
event: 12, | |
end: 9, | |
error: 5, | |
dispose: 3 | |
}; | |
const typeLabels = { | |
'construct': 'CONSTRUCT', | |
'run': ' RUN ', | |
'event': ' EVENT ', | |
'end': ' END ', | |
'error': ' ERROR ', | |
'dispose': ' DISPOSE ', | |
}; | |
const instanceIdMap = {}; | |
function hashString(str) { | |
var hash = 5381, i = str.length; | |
while(i) hash = (hash * 33) ^ str.charCodeAt(--i); | |
return hash >>> 0; | |
} | |
// function hashString(s) { | |
// let n = 0; | |
// for(let i = 0; i < s.length; i++) { | |
// n += s.charCodeAt(i); | |
// } | |
// return n; | |
// } | |
function safe(id) { | |
return typeof id === 'symbol' | |
? id.toString().substr(7, id.toString().length - 8) | |
: id; | |
} | |
const colorWheelSize = 12; | |
function chooseStyle(id) { | |
var number; | |
if(id === null || id === void 0) number = 0; | |
else if(typeof id !== 'number') number = hashString(safe(id)); | |
else number = id; | |
var index = number % colorWheelSize; | |
var isMid = number % (colorWheelSize*2) > colorWheelSize; | |
var isInverse = number % (colorWheelSize*4) > colorWheelSize*2; | |
return isInverse ? colorFillInv(index, isMid) : colorFill(index, isMid); | |
} | |
var startTime = Date.now(); | |
function fmtTime(t) { | |
const time = t === void 0 ? Date.now() : t; | |
return formatDate(time - startTime, 'mm:ss.SSS'); | |
} | |
function nextInstanceId(hash) { | |
if(!hash) return void 0; | |
return [instanceIdMap[hash] = (instanceIdMap[hash] || 0) + 1, Date.now()]; | |
} | |
function isImmutableCollection(x) { | |
return x ? x instanceof Immutable.Collection : false; | |
} | |
function isImmutableClass(x) { | |
return x && typeof x === 'object' && isImmutableCollection(x._state) && x._state.has('$version'); | |
} | |
function versionOf(cls) { | |
const version = cls.version; | |
return typeof version === 'object' ? version.number : version || 0; | |
} | |
const undefinedType = Symbol('DataType: Undefined'); | |
const nullType = Symbol('DataType: Null'); | |
const objectType = Symbol('DataType: Object'); | |
const arrayType = Symbol('DataType: Array'); | |
const dateType = Symbol('DataType: Date'); | |
const immutableCollectionType = Symbol('DataType: Immutable Collection'); | |
const immutableClassType = Symbol('DataType: Immutable Class'); | |
const stringType = Symbol('DataType: String'); | |
const numberType = Symbol('DataType: Number'); | |
const booleanType = Symbol('DataType: Boolean'); | |
const functionType = Symbol('DataType: Function'); | |
const symbolType = Symbol('DataType: Symbol'); | |
function determineType(x) { | |
if(x === void 0) return undefinedType; | |
if(x === null) return nullType; | |
if(typeof x === 'object') { | |
if(x instanceof Date) return dateType; | |
if(isImmutableClass(x)) return immutableClassType; | |
if(isImmutableCollection(x)) return immutableCollectionType; | |
if(Array.isArray(x)) return arrayType; | |
return objectType; | |
} | |
if(typeof x === 'string') return stringType; | |
if(typeof x === 'number') return numberType; | |
if(typeof x === 'function') return functionType; | |
if(typeof x === 'boolean') return booleanType; | |
if(typeof x === 'symbol') return symbolType; | |
console.warn('Unable to emit debug information for unexpected type:', x, typeof x); | |
return objectType; | |
} | |
function describeType(type, x) { | |
switch(type) { | |
case undefinedType: return 'undefined'; | |
case nullType: return 'null'; | |
case objectType: return x.constructor.name || 'UnnamedType'; | |
case arrayType: return `Array[${x.length}]`; | |
case dateType: return `Date(${formatDate(x)})`; | |
case immutableClassType: return `${x.constructor.name}(V:${versionOf(x)})`; | |
case stringType: return `String(${x.length})`; | |
case numberType: return `Number(${x})`; | |
case functionType: return `Function(${x.name})`; | |
case booleanType: return `Bool(${x})`; | |
case symbolType: return x.toString(); | |
default: return 'Unknown'; | |
} | |
} | |
function describe(value, abbreviate) { | |
const type = determineType(value); | |
const descr = abbreviate ? abbreviate(value) : describeType(type, value); | |
return {value, type, descr}; | |
} | |
function isNullOrUndefined(type) { | |
return type === nullType || type === undefinedType; | |
} | |
function padStr(str, len) { | |
const pad = len - str.length; | |
if(pad <= 0) return str; | |
const left = Math.floor(pad/2); | |
const right = pad - left; | |
const a = new Array(left + 1).join(' '); | |
const b = new Array(right + 1).join(' '); | |
return a + str + b; | |
} | |
class MessageBuilder | |
{ | |
constructor() { | |
this.message = []; | |
this.args = []; | |
this._method = console.log; | |
this._append(fmtTime(), {style: colorFill(13), pad: true}); | |
} | |
static create() { | |
return new MessageBuilder(); | |
} | |
_tokenize(str, {style, prefix = '', suffix = '', pad = false} = {}) { | |
const delimiter = style ? '%c' : ''; | |
const padLeft = pad === true || pad === 'left' ? ' ' : ''; | |
const padRight = pad === true || pad === 'right' ? ' ' : ''; | |
const token = `${prefix}${delimiter}${padLeft}${str}${padRight}${delimiter}${suffix}`; | |
const args = style ? [style, ''] : []; | |
return [token, args]; | |
} | |
_appendFinal(msg, args) { | |
this.message.push(msg); | |
this.args.push(...args); | |
} | |
_append() { | |
const [msg, args] = this._tokenize.apply(this, arguments); | |
this._appendFinal(msg, args); | |
} | |
_mergeTokens(tokens) { | |
return tokens.reduce((a, token) => [a[0] + token[0], a[1].concat(token[1])], ['', []]); | |
} | |
_appendTokens(tokens) { | |
const [msg, args] = this._mergeTokens(tokens); | |
this._appendFinal(msg, args); | |
} | |
_tokenizeDescr(x, disposition) { | |
var style; | |
switch(disposition) { | |
case 'good': style = colorText(10); break; | |
case 'bad': | |
style = colorText(5); | |
this._method = console.warn; | |
break; | |
case 'warn': | |
if(this._method !== console.warn) { | |
this._method = console.debug; | |
} | |
style = colorText(7); break; | |
case 'flag': | |
if(this._method !== console.warn) { | |
this._method = console.debug; | |
} | |
style = colorText(9); break; | |
case 'minor': style = dimTextStyle; break; | |
default: style = colorText(12); break; | |
} | |
return this._tokenize(x.descr, {style}); | |
} | |
setType(type) { | |
this._append(typeLabels[type], {style: colorFill(colorsByEventType[type]), pad: true}); | |
return this; | |
} | |
setSource(source) { | |
const name = source.constructor.name; | |
this._append(padStr(name, 18), {style: chooseStyle(name), pad: true}); | |
return this; | |
} | |
_appendInstance(type, id, t) { | |
this._appendTokens([ | |
this._tokenize(`${type}:`, {style: colorFill(1), pad: 'left'}), | |
this._tokenize(id, {style: chooseStyle(id)}), | |
this._tokenize(fmtTime(t), {style: colorFill(1, true), pad: true}) | |
]); | |
} | |
setSourceInstance([id, t]) { | |
this._appendInstance('SRC', id, t); | |
return this; | |
} | |
setSinkInstance([id, t]) { | |
this._appendInstance('SNK', id, t); | |
return this; | |
} | |
setName(name) { | |
if(name !== void 0) { | |
this._append(name, {style: colorFillInv(12), pad: true}); | |
} | |
return this; | |
} | |
setCount(count) { | |
this._append(count, {style: colorText(12), prefix: '[#', suffix: ']'}); | |
return this; | |
} | |
setAbbreviatedValue(showPreviousType, current, previous, dispositions) { | |
this._a = previous; | |
this._b = current; | |
const aIsNil = isNullOrUndefined(previous.type); | |
const bIsNil = isNullOrUndefined(current.type); | |
const aIsClass = previous.type === immutableClassType; | |
const bIsClass = current.type === immutableClassType; | |
let aDisposition, bDisposition; | |
if(aIsClass || bIsClass) { | |
if(aIsClass && bIsClass) { | |
const va = versionOf(previous.value); | |
const vb = versionOf(current.value); | |
const disposition = vb === va + 1 ? 'good' | |
: vb > va ? 'warn' | |
: vb === va ? 'minor' | |
: 'bad'; | |
aDisposition = bDisposition = disposition; | |
} | |
else { | |
aDisposition = aIsClass ? bIsNil ? 'minor' : 'bad' : aIsNil ? 'minor' : 'bad'; | |
bDisposition = bIsClass ? aIsNil ? null : 'bad' : bIsNil ? 'minor' : 'bad'; | |
} | |
} | |
else { | |
aDisposition = aIsNil ? 'minor' : bIsNil || previous.type === current.type ? 'minor' : 'bad'; | |
bDisposition = bIsNil ? 'minor' : aIsNil || previous.type === current.type ? null : 'bad'; | |
} | |
if(dispositions) { | |
if(Array.isArray(dispositions)) { | |
aDisposition = dispositions[0] || aDisposition; | |
bDisposition = dispositions[1] || bDisposition; | |
} | |
else { | |
aDisposition = dispositions || aDisposition; | |
bDisposition = dispositions || bDisposition; | |
} | |
} | |
const tokens = [this._tokenize('[')]; | |
if(showPreviousType) { | |
tokens.push(this._tokenizeDescr(previous, aDisposition)); | |
tokens.push(this._tokenize(' => ', {style: colorText(13)})); | |
} | |
tokens.push(this._tokenizeDescr(current, bDisposition)); | |
tokens.push(this._tokenize(']')); | |
this._appendTokens(tokens); | |
return this; | |
} | |
includeValueInspector() { | |
this.args.push(new InspectableValue(this._a, this._b)); | |
} | |
write(extraValues) { | |
const args = [this.message.join(' ')].concat(this.args); | |
if(extraValues.length) { | |
args.push(...extraValues); | |
} | |
this._method.apply(console, args); | |
} | |
}; | |
const globalTraceId = Symbol('Global Trace'); | |
const traces = {}; | |
function initContext(trace) { | |
if(trace._context) { | |
trace.context = trace.options.init ? trace.options.init() : trace._context; | |
delete trace._context; | |
} | |
return trace; | |
} | |
function processTrace(eventContext, trace) { | |
const type = eventContext.eventType; | |
const options = trace.options; | |
if(options['pre']) { | |
const result = options['pre'](eventContext); | |
if(typeof result === 'boolean') return result; | |
} | |
if(options[type]) { | |
const result = options[type](eventContext); | |
if(typeof result === 'boolean') return result; | |
} | |
switch(eventContext.eventType) { | |
case 'event': | |
case 'end': | |
case 'error': | |
if(options['events']) { | |
const result = options['events'](eventContext); | |
if(typeof result === 'boolean') return result; | |
} | |
break; | |
case 'construct': | |
case 'run': | |
case 'dispose': | |
if(options['lifecycle']) { | |
const result = options['lifecycle'](eventContext); | |
if(typeof result === 'boolean') return result; | |
} | |
break; | |
} | |
if(options['*']) { | |
const result = options['*'](eventContext); | |
if(typeof result === 'boolean') return result; | |
} | |
} | |
function option(name, sources) { | |
const trace = sources.find(source => source && source.options && name in source.options); | |
return trace && trace.options[name]; | |
} | |
function processUnionedTrace(sources, eventContext) { | |
let cancel = void 0; | |
const silent = valueOrDefault(option('silent', sources), false); | |
for(let trace of sources) { | |
if(trace) { | |
const result = processTrace(eventContext, trace); | |
if(typeof result === 'boolean' && cancel === void 0) cancel = result; | |
if(silent !== (cancel === false)) return false; | |
} | |
} | |
return true; | |
} | |
function traceUnion(localTrace, namedTrace, globalTrace) { | |
const sources = [ | |
localTrace, | |
namedTrace && initContext(namedTrace), | |
globalTrace && initContext(globalTrace) | |
]; | |
return new Proxy({}, { | |
get(target, name) { | |
switch(name) { | |
case 'local': return sources[0]; | |
case 'named': return sources[1]; | |
case 'global': return sources[2]; | |
case 'execute': return context => processUnionedTrace(sources, context); | |
default: | |
return name in target ? target[name] : target[name] = option(name, sources); | |
} | |
} | |
}); | |
} | |
function valueOrDefault(value, defaultValue) { | |
return value === void 0 ? defaultValue : value; | |
} | |
class Logger | |
{ | |
constructor(options) { | |
this.options = options; | |
} | |
clone(options) { | |
return new Logger(Object.assign({}, this.options, options)); | |
} | |
write(eventType, value) { | |
const [hasTrace, trace] = (() => { | |
const localTrace = this.options.trace && {context:{}, options: this.options.trace}; | |
const namedTrace = this.options.name && traces[this.options.name]; | |
const globalTrace = traces[globalTraceId]; | |
const hasTrace = localTrace || namedTrace || globalTrace; | |
return [hasTrace, traceUnion(localTrace, namedTrace, globalTrace)]; | |
})(); | |
const isSinkEvent = arguments.length === 2; | |
const currentValue = isSinkEvent && describe(value, value !== void 0 && trace.abbreviate); | |
let previousValue; | |
const extraValues = []; | |
if(isSinkEvent) { | |
this._count = (this._count || 0) + 1; | |
previousValue = this._previousValue || describe(void 0); | |
this._previousValue = currentValue; | |
} | |
const showPreviousType = valueOrDefault(trace.showPreviousType, true); | |
const getDisposition = trace.disposition; | |
if(hasTrace) { | |
const contextType = isSinkEvent ? EventContext : DebugContext; | |
const eventContext = new contextType(eventType, this.options, extraValues, trace, currentValue, previousValue, this._count); | |
if(!trace.execute(eventContext)) return; | |
} | |
const msg = MessageBuilder.create() | |
.setType(eventType) | |
.setSource(this.options.source) | |
.setSourceInstance(this.options.sourceInstanceId); | |
if('sinkInstanceId' in this.options) { | |
msg.setSinkInstance(this.options.sinkInstanceId); | |
if(isSinkEvent) { | |
msg.setCount(this._count); | |
} | |
}; | |
if(this.options.name) { | |
msg.setName(this.options.name); | |
} | |
if(isSinkEvent) { | |
const dispositions = getDisposition && getDisposition(currentValue.value, previousValue.value); | |
msg.setAbbreviatedValue(showPreviousType, currentValue, previousValue, dispositions); | |
if(trace.inspect) { | |
msg.includeValueInspector(); | |
} | |
} | |
msg.write(extraValues); | |
} | |
}; | |
const globalContext = {}; | |
class DebugContext | |
{ | |
constructor(eventType, options, extraValues, trace) { | |
this._eventType = eventType; | |
this._options = options; | |
this._values = extraValues; | |
this._trace = trace; | |
} | |
get eventType() { return this._eventType; } | |
get name() { return this._options.name; } | |
get localContext() { return this._trace.local && this._trace.local.context; } | |
get traceContext() { return this._trace.named && this._trace.named.context; } | |
get globalContext() { return this._trace.global && this._trace.global.context; } | |
get src() { | |
return this._src || (this._src = { | |
id: this._options.sourceInstanceId[0], | |
name: this._options.source.constructor.name, | |
context: this._options.sourceContext, | |
ref: this._options.source | |
}); | |
} | |
get snk() { | |
return this._options.sink && (this._snk || (this._snk = { | |
id: this._options.sinkInstanceId[0], | |
name: this._options.sink.constructor.name, | |
context: this._options.sinkContext, | |
ref: this._options.sink | |
})); | |
} | |
log(...x) { this._values.push(...x); } | |
} | |
class EventContext extends DebugContext | |
{ | |
constructor(eventType, options, extraValues, trace, current, previous, count) { | |
super(eventType, options, extraValues, trace); | |
this._current = current; | |
this._previous = previous; | |
this._count = count; | |
} | |
get diff() { | |
if('_diff' in this) return this._diff; | |
const a = this._previous; | |
const b = this._current; | |
if(a.type === immutableClassType && b.type === immutableClassType) { | |
return this._diff = immutableDiff(a.value.state, b.value.state).toJS(); | |
} | |
return this._diff = void 0; | |
} | |
get count() { return this._count; } | |
get value() { return this._current.value; } | |
get previous() { return this._previous.value; } | |
} | |
function summarizeValue(value) { | |
const x = describe(value); | |
return describeType(x.type, x.value); | |
} | |
function refineImmutableDiff(diff) { | |
const result = diff | |
.map(change => { | |
if(change.path.endsWith('$version') && change.op !== 'remove') { | |
return `VERSION (#${change.value}) at ${change.path.substr(0, change.path.length - 9)||'/'}`; | |
} | |
switch(change.op) { | |
case 'add': return `ADD (${summarizeValue(change.value)}) at ${change.path}`; | |
case 'replace': return `UPDATE (${summarizeValue(change.value)}) at ${change.path}`; | |
case 'remove': return 'DELETE: ' + change.path; | |
default: return change; | |
} | |
}) | |
.filter(x => x); | |
return result && result.length ? result.length === 1 ? result[0] : result : 'NO CHANGES'; | |
} | |
function InspectableValue(a, b) { | |
let _diff; | |
let hasDiff; | |
if(a.type === immutableClassType) { | |
if(b.type === immutableClassType) { | |
Object.defineProperty(this, 'diff', { | |
get() { | |
if(!_diff) _diff = immutableDiff(a.value.state, b.value.state).toJS(); | |
return _diff; | |
} | |
}); | |
hasDiff = true; | |
} | |
let _version; | |
Object.defineProperty(this, 'version', { | |
get() { | |
if(!_version) { | |
if(typeof a.value.version === 'number') { | |
_version = a.value.version; | |
} | |
else { | |
_version = { | |
number: a.value.version.number, | |
updates: a.value.version.updates.map(u => u.type + ': [' + u.args.join(', ') + ']') | |
}; | |
} | |
} | |
return _version; | |
} | |
}); | |
} | |
else if(a.type === immutableCollectionType && b.type === immutableCollectionType) { | |
Object.defineProperty(this, 'diff', { | |
get() { | |
if(!_diff) _diff = immutableDiff(a.value, b.value).toJS(); | |
return _diff; | |
} | |
}); | |
hasDiff = true; | |
} | |
if(hasDiff) { | |
Object.defineProperty(this, 'summary', { | |
get() { return refineImmutableDiff(this.diff); } | |
}); | |
} | |
Object.defineProperty(this, 'previous', { | |
get() { return a.value; } | |
}); | |
Object.defineProperty(this, 'current', { | |
get() { return b.value; } | |
}); | |
} | |
class DebugSource | |
{ | |
constructor(source, name, trace) { | |
this.source = source; | |
const stackStr = new Error().stack.toString().split(/\n/g).slice(4, 7).join('\n'); | |
const hash = hashString((name ? name + ';' : '') + stackStr); | |
const sourceInstanceId = nextInstanceId(hash); | |
this.logger = new Logger({ | |
name, | |
source, | |
sourceInstanceId, | |
sourceContext: {}, | |
trace | |
}); | |
this.logger.write('construct'); | |
} | |
run(sink, scheduler) { | |
const name = this.name; | |
const sinkInstanceId = nextInstanceId(`sink.${this.logger.options.sourceInstanceId}` + name); | |
const debugSink = new DebugSink(sink, this.logger.clone({ | |
sink, | |
sinkInstanceId, | |
sinkContext: {}, | |
})); | |
debugSink.logger.write('run'); | |
const disposable = this.source.run(debugSink, scheduler); | |
return { | |
dispose() { | |
debugSink.logger.write('dispose'); | |
disposable.dispose(); | |
} | |
}; | |
} | |
} | |
class DebugSink | |
{ | |
constructor(sink, msg) { | |
this.sink = sink; | |
this.logger = msg; | |
} | |
event(t, x) { | |
this.logger.write('event', x); | |
this.sink.event(t, x); | |
} | |
end(t, x) { | |
this.logger.write('end', x); | |
this.sink.end(t, x); | |
} | |
error(t, e) { | |
this.logger.write('error', e); | |
this.sink.error(t, e); | |
} | |
} | |
function debug(name, trace) { | |
if(!trace && typeof name === 'object') { | |
trace = name; | |
name = void 0; | |
} | |
if(name === null) name = void 0; | |
if(trace === null) trace = void 0; | |
return stream => new stream.constructor(new DebugSource(stream.source, name, trace)); | |
} | |
debug.trace = function registerDebugTrace(id, options) { | |
let context; | |
if(arguments.length === 1) { | |
options = id; | |
id = globalTraceId; | |
context = globalContext; | |
} | |
else { | |
context = {}; | |
} | |
traces[id] = {_context: context, options}; | |
return debug; | |
}; | |
export default debug; | |
/* eslint-enable no-underscore-dangle */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment