├── src ├── Component │ ├── react-inspector │ │ ├── util.ts │ │ ├── elements.tsx │ │ └── index.tsx │ ├── theme │ │ ├── index.ts │ │ └── default.ts │ ├── message-parsers │ │ ├── Formatted.tsx │ │ ├── Object.tsx │ │ └── Error.tsx │ ├── devtools-parser │ │ ├── index.ts │ │ ├── format-message.ts │ │ └── string-utils.ts │ ├── __tests__ │ │ └── Console.spec.tsx │ ├── index.tsx │ ├── elements.tsx │ └── Message.tsx ├── definitions │ ├── Store.d.ts │ ├── Payload.d.ts │ ├── Methods.ts │ ├── Console.d.ts │ ├── Component.d.ts │ └── Styles.d.ts ├── Hook │ ├── construct.ts │ ├── store │ │ ├── state.ts │ │ ├── dispatch.ts │ │ ├── actions.ts │ │ └── reducer.ts │ ├── parse │ │ ├── methods │ │ │ ├── assert.ts │ │ │ ├── count.ts │ │ │ └── timing.ts │ │ ├── GUID.ts │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── Parse.spec.tsx.snap │ │ │ └── Parse.spec.tsx │ │ └── index.ts │ ├── __tests__ │ │ ├── Log.tsx │ │ ├── console.ts │ │ ├── __snapshots__ │ │ │ └── Hook.spec.tsx.snap │ │ └── Hook.spec.tsx │ └── index.ts ├── index.ts ├── Unhook │ └── index.ts └── Transform │ ├── index.ts │ ├── arithmetic.ts │ ├── Map.ts │ ├── Function.ts │ ├── HTML.ts │ └── replicator │ └── index.ts ├── .gitignore ├── .prettierrc ├── tsconfig.build.json ├── scripts └── test.js ├── tsconfig.json ├── nwb.config.js ├── .vscode └── launch.json ├── .circleci └── config.yml ├── demo ├── src │ └── index.tsx └── public │ └── iframe.html ├── package.json └── README.md /src/Component/react-inspector/util.ts: -------------------------------------------------------------------------------- 1 | export const isMinusZero = value => 1 / value === -Infinity 2 | -------------------------------------------------------------------------------- /src/definitions/Store.d.ts: -------------------------------------------------------------------------------- 1 | export interface Action { 2 | type: string 3 | [key: string]: any 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | .DS_Store -------------------------------------------------------------------------------- /src/definitions/Payload.d.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './Console' 2 | 3 | export interface Payload extends Message { 4 | id: string 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": false, 5 | "singleQuote": true, 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /src/Hook/construct.ts: -------------------------------------------------------------------------------- 1 | const construct = (name: string): any => ({ 2 | constructor: { 3 | name 4 | } 5 | }) 6 | 7 | export default construct 8 | -------------------------------------------------------------------------------- /src/Hook/store/state.ts: -------------------------------------------------------------------------------- 1 | import { initialState } from './reducer' 2 | export let state: typeof initialState 3 | 4 | export function update(newState: any) { 5 | state = newState 6 | } 7 | -------------------------------------------------------------------------------- /src/Component/theme/index.ts: -------------------------------------------------------------------------------- 1 | import styled, { ThemedReactEmotionInterface } from 'react-emotion' 2 | import { Theme } from '../../definitions/Component' 3 | 4 | export default styled as ThemedReactEmotionInterface 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Console } from './Component' 2 | export { default as Hook } from './Hook' 3 | export { default as Unhook } from './Unhook' 4 | 5 | export { Decode } from './Transform' 6 | export { Encode } from './Transform' 7 | -------------------------------------------------------------------------------- /src/Hook/store/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../../definitions/Store' 2 | import reduce from './reducer' 3 | import { state, update } from './state' 4 | 5 | function dispatch(action: Action) { 6 | update(reduce(state, action)) 7 | } 8 | 9 | export default dispatch 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "exclude": [ 7 | "./node_modules", 8 | "./lib", 9 | "./demo", 10 | "./scripts", 11 | "./src/**/__tests__/*", 12 | "./nwb.config.js" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/Hook/parse/methods/assert.ts: -------------------------------------------------------------------------------- 1 | export function test(expression: any, ...messages: any[]): any { 2 | if (expression) return false 3 | 4 | // Default message 5 | if (messages.length === 0) messages.push('console.assert') 6 | 7 | return { 8 | method: 'error', 9 | data: [`Assertion failed:`, ...messages] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines the React 16 Adapter for Enzyme. 3 | * 4 | * @link http://airbnb.io/enzyme/docs/installation/#working-with-react-16 5 | * @copyright 2017 Airbnb, Inc. 6 | */ 7 | const enzyme = require('enzyme') 8 | const Adapter = require('enzyme-adapter-react-16') 9 | 10 | enzyme.configure({ adapter: new Adapter() }) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "module": "commonjs", 5 | "target": "es3", 6 | "lib": ["es6", "dom"], 7 | "inlineSourceMap": true, 8 | "jsx": "react", 9 | "moduleResolution": "node", 10 | "rootDir": ".", 11 | "forceConsistentCasingInFileNames": true, 12 | "types": ["jest"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Hook/store/actions.ts: -------------------------------------------------------------------------------- 1 | export function count(name: string) { 2 | return { 3 | type: 'COUNT', 4 | name 5 | } 6 | } 7 | 8 | export function timeStart(name: string) { 9 | return { 10 | type: 'TIME_START', 11 | name 12 | } 13 | } 14 | 15 | export function timeEnd(name: string) { 16 | return { 17 | type: 'TIME_END', 18 | name 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Hook/parse/methods/count.ts: -------------------------------------------------------------------------------- 1 | import { state } from '../../store/state' 2 | import dispatch from '../../store/dispatch' 3 | import { count } from '../../store/actions' 4 | 5 | export function increment(label: string): any { 6 | dispatch(count(label)) 7 | const times = state.count[label] 8 | 9 | return { 10 | method: 'log', 11 | data: [`${label}: ${times}`] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Hook/parse/GUID.ts: -------------------------------------------------------------------------------- 1 | export default function guidGenerator(): string { 2 | let S4 = function() { 3 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1) 4 | } 5 | return ( 6 | S4() + 7 | S4() + 8 | '-' + 9 | S4() + 10 | '-' + 11 | S4() + 12 | '-' + 13 | S4() + 14 | '-' + 15 | S4() + 16 | '-' + 17 | Date.now() 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/Hook/__tests__/Log.tsx: -------------------------------------------------------------------------------- 1 | import console from './console' 2 | 3 | function Log(type: string, ...data: any[]) { 4 | return new Promise((resolve, reject) => { 5 | const length = console.logs.length 6 | console[type](...data) 7 | 8 | setTimeout(() => { 9 | if (console.logs.length !== length) { 10 | resolve(console.logs[console.logs.length - 1]) 11 | } 12 | reject() 13 | }) 14 | }) 15 | } 16 | 17 | export default Log 18 | -------------------------------------------------------------------------------- /src/definitions/Methods.ts: -------------------------------------------------------------------------------- 1 | const methods = [ 2 | 'log', 3 | 'debug', 4 | 'info', 5 | 'warn', 6 | 'error', 7 | 'table', 8 | 'clear', 9 | 'time', 10 | 'timeEnd', 11 | 'count', 12 | 'assert' 13 | ] 14 | 15 | export default methods 16 | 17 | export type Methods = 18 | | 'log' 19 | | 'debug' 20 | | 'info' 21 | | 'warn' 22 | | 'error' 23 | | 'table' 24 | | 'clear' 25 | | 'time' 26 | | 'timeEnd' 27 | | 'count' 28 | | 'assert' 29 | -------------------------------------------------------------------------------- /src/definitions/Console.d.ts: -------------------------------------------------------------------------------- 1 | import { Methods } from './Methods' 2 | 3 | export interface Storage { 4 | pointers: { 5 | [name: string]: Function 6 | } 7 | src: any 8 | } 9 | 10 | export interface HookedConsole extends Console { 11 | feed: Storage 12 | } 13 | 14 | export type Methods = Methods 15 | 16 | export interface Message { 17 | method: Methods 18 | data?: any[] 19 | } 20 | 21 | export type Callback = (encoded: any, message: Message) => void 22 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: false, 5 | umd: false 6 | }, 7 | webpack: { 8 | config(config) { 9 | config.entry = { 10 | demo: ['./demo/src/index.tsx'] 11 | } 12 | config.resolve.extensions.push('.ts', '.tsx') 13 | config.module.rules.push({ 14 | test: /\.tsx?$/, 15 | loader: 'ts-loader' 16 | }) 17 | 18 | return config 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Hook/__tests__/console.ts: -------------------------------------------------------------------------------- 1 | import { HookedConsole } from '../../definitions/Console' 2 | 3 | interface Console extends HookedConsole { 4 | logs: any[] 5 | $log: Function 6 | } 7 | 8 | declare const console: Console 9 | console.logs = [] 10 | ;['log', 'warn', 'info', 'error', 'debug', 'assert', 'time', 'timeEnd'].forEach( 11 | (method) => { 12 | console[`$${method}`] = console[method] 13 | console[method] = () => {} 14 | } 15 | ) 16 | 17 | export default console 18 | -------------------------------------------------------------------------------- /src/Hook/parse/__tests__/__snapshots__/Parse.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`asserts values: assertion failed 1`] = ` 4 | Object { 5 | "data": Array [ 6 | "Assertion failed:", 7 | "console.assert", 8 | ], 9 | "id": "assert-false", 10 | "method": "error", 11 | } 12 | `; 13 | 14 | exports[`time non existent label: non existent timer 1`] = ` 15 | Object { 16 | "data": Array [ 17 | "Timer 'nonExistent' does not exist", 18 | ], 19 | "id": "timer-fail", 20 | "method": "warn", 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /src/Unhook/index.ts: -------------------------------------------------------------------------------- 1 | import { HookedConsole } from '../definitions/Console' 2 | 3 | /** 4 | * Unhook a console constructor and restore back the Native methods 5 | * @argument console The Console constructor to Hook 6 | */ 7 | function Unhook(console: HookedConsole): boolean { 8 | if (console.feed) { 9 | for (const method of Object.keys(console.feed.pointers)) { 10 | console[method] = console.feed.pointers[method] 11 | } 12 | return delete console.feed 13 | } else { 14 | return false 15 | } 16 | } 17 | 18 | export default Unhook 19 | -------------------------------------------------------------------------------- /src/Component/message-parsers/Formatted.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Root } from '../react-inspector/elements' 3 | 4 | import Format from '../devtools-parser' 5 | 6 | interface Props { 7 | data: any[] 8 | } 9 | 10 | class Formatted extends React.PureComponent { 11 | render() { 12 | return ( 13 | 19 | ) 20 | } 21 | } 22 | 23 | export default Formatted 24 | -------------------------------------------------------------------------------- /src/Component/devtools-parser/index.ts: -------------------------------------------------------------------------------- 1 | import * as Linkify from 'linkifyjs/html' 2 | import formatMessageString from './format-message' 3 | 4 | /** 5 | * Formats a console log message using the Devtools parser and returns HTML 6 | * @param args The arguments passed to the console method 7 | */ 8 | function formatMessage(args: any[]): string { 9 | const formattedResult = document.createElement('span') 10 | 11 | formatMessageString(args[0], args.slice(1), formattedResult) 12 | 13 | return Linkify(formattedResult.outerHTML.replace(/(?:\r\n|\r|\n)/g, '
')) 14 | } 15 | 16 | export default formatMessage 17 | -------------------------------------------------------------------------------- /src/Transform/index.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../definitions/Console' 2 | import Arithmetic from './arithmetic' 3 | import Function from './Function' 4 | import HTML from './HTML' 5 | import Map from './Map' 6 | 7 | import Replicator from './replicator' 8 | 9 | const transforms = [HTML, Function, Arithmetic, Map] 10 | 11 | const replicator = new Replicator() 12 | replicator.addTransforms(transforms) 13 | 14 | export function Encode(data: any): T { 15 | return JSON.parse(replicator.encode(data)) 16 | } 17 | 18 | export function Decode(data: any): Message { 19 | return replicator.decode(JSON.stringify(data)) 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "vscode-jest-tests", 10 | "request": "launch", 11 | "args": [ 12 | "--runInBand" 13 | ], 14 | "cwd": "${workspaceFolder}", 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen", 17 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/Hook/parse/methods/timing.ts: -------------------------------------------------------------------------------- 1 | import { state } from '../../store/state' 2 | import dispatch from '../../store/dispatch' 3 | import { timeStart, timeEnd } from '../../store/actions' 4 | 5 | export function start(label: string) { 6 | dispatch(timeStart(label)) 7 | } 8 | 9 | export function stop(label: string): any { 10 | const timing = state.timings[label] 11 | if (timing && !timing.end) { 12 | dispatch(timeEnd(label)) 13 | const { time } = state.timings[label] 14 | 15 | return { 16 | method: 'log', 17 | data: [`${label}: ${time}ms`] 18 | } 19 | } 20 | return { 21 | method: 'warn', 22 | data: [`Timer '${label}' does not exist`] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/definitions/Component.d.ts: -------------------------------------------------------------------------------- 1 | import { Payload } from './Payload' 2 | import { Styles } from './Styles' 3 | import { Methods } from './Methods' 4 | 5 | export type Variants = 'light' | 'dark' 6 | 7 | export interface Theme { 8 | variant: Variants 9 | styles: Styles 10 | } 11 | 12 | export interface Context extends Theme { 13 | method: Methods 14 | } 15 | 16 | export interface Message extends Payload { 17 | data: any[] 18 | } 19 | 20 | export interface Props { 21 | logs: Message[] 22 | variant?: Variants 23 | styles?: Styles 24 | filter?: Methods[] 25 | searchKeywords?: string 26 | logFilter?: Function 27 | } 28 | 29 | export interface MessageProps { 30 | log: Message 31 | } 32 | -------------------------------------------------------------------------------- /src/Transform/arithmetic.ts: -------------------------------------------------------------------------------- 1 | enum Arithmetic { 2 | infinity, 3 | minusInfinity, 4 | minusZero 5 | } 6 | 7 | function isMinusZero(value) { 8 | return 1 / value === -Infinity 9 | } 10 | 11 | export default { 12 | type: 'Arithmetic', 13 | shouldTransform(type: any, value: any) { 14 | return ( 15 | type === 'number' && 16 | (value === Infinity || value === -Infinity || isMinusZero(value)) 17 | ) 18 | }, 19 | toSerializable(value): Arithmetic { 20 | return value === Infinity 21 | ? Arithmetic.infinity 22 | : value === -Infinity 23 | ? Arithmetic.minusInfinity 24 | : Arithmetic.minusZero 25 | }, 26 | fromSerializable(data: Arithmetic) { 27 | if (data === Arithmetic.infinity) return Infinity 28 | if (data === Arithmetic.minusInfinity) return -Infinity 29 | if (data === Arithmetic.minusZero) return -0 30 | 31 | return data 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Transform/Map.ts: -------------------------------------------------------------------------------- 1 | interface Storage { 2 | name: string 3 | body: object 4 | proto: string 5 | } 6 | 7 | /** 8 | * Serialize a Map into JSON 9 | */ 10 | export default { 11 | type: 'Map', 12 | shouldTransform(type: any, obj: any) { 13 | return obj && obj.constructor && obj.constructor.name === 'Map' 14 | }, 15 | toSerializable(map: any): Storage { 16 | let body = {} 17 | 18 | map.forEach(function(value, key) { 19 | const k = typeof key == 'object' ? JSON.stringify(key) : key 20 | body[k] = value 21 | }) 22 | 23 | return { 24 | name: 'Map', 25 | body, 26 | proto: Object.getPrototypeOf(map).constructor.name 27 | } 28 | }, 29 | fromSerializable(data: Storage) { 30 | const { body } = data 31 | let obj = { ...body } 32 | 33 | if (typeof data.proto === 'string') { 34 | // @ts-ignore 35 | obj.constructor = { 36 | name: data.proto 37 | } 38 | } 39 | 40 | return obj 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:7.10 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/console-feed 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: yarn install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: yarn test 38 | -------------------------------------------------------------------------------- /src/Component/__tests__/Console.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import Console from '..' 5 | 6 | it('renders', () => { 7 | const result = shallow( 8 | 17 | ) 18 | 19 | expect(result.html()).toContain('my-log') 20 | }) 21 | 22 | it('formats messages', () => { 23 | const result = shallow( 24 | 33 | ) 34 | 35 | expect(result.html()).toContain('test') 36 | }) 37 | 38 | it('displays object names', () => { 39 | const result = shallow( 40 | 49 | ) 50 | 51 | expect(result.html()).toContain('MyObject {}') 52 | }) 53 | -------------------------------------------------------------------------------- /src/Component/react-inspector/elements.tsx: -------------------------------------------------------------------------------- 1 | import styled from '../theme' 2 | import { Context } from '../../definitions/Component' 3 | 4 | interface Props { 5 | theme: Context 6 | } 7 | 8 | /** 9 | * Object root 10 | */ 11 | export const Root = styled('div')({ 12 | display: 'inline-block', 13 | wordBreak: 'break-all', 14 | '&::after': { 15 | content: `' '`, 16 | display: 'inline-block' 17 | }, 18 | '& > li': { 19 | backgroundColor: 'transparent !important', 20 | display: 'inline-block' 21 | }, 22 | '& ol:empty': { 23 | paddingLeft: '0 !important' 24 | } 25 | }) 26 | 27 | /** 28 | * Table 29 | */ 30 | export const Table = styled('span')({ 31 | '& > li': { 32 | display: 'inline-block', 33 | marginTop: 5 34 | } 35 | }) 36 | 37 | /** 38 | * HTML 39 | */ 40 | export const HTML = styled('span')({ 41 | display: 'inline-block', 42 | '& div:hover': { 43 | backgroundColor: 'rgba(255, 220, 158, .05) !important', 44 | borderRadius: '2px' 45 | } 46 | }) 47 | 48 | /** 49 | * Object constructor 50 | */ 51 | export const Constructor = styled('span')({ 52 | '& > span > span:nth-child(1)': { 53 | opacity: 0.6 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /src/Hook/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../../definitions/Store' 2 | 3 | export const initialState = { 4 | timings: {}, 5 | count: {} 6 | } 7 | 8 | export default (state = initialState, action: Action) => { 9 | switch (action.type) { 10 | case 'COUNT': { 11 | const times = state.count[action.name] || 0 12 | 13 | return { 14 | ...state, 15 | count: { 16 | ...state.count, 17 | [action.name]: times + 1 18 | } 19 | } 20 | } 21 | 22 | case 'TIME_START': { 23 | return { 24 | ...state, 25 | timings: { 26 | ...state.timings, 27 | [action.name]: { 28 | start: performance.now() || +new Date() 29 | } 30 | } 31 | } 32 | } 33 | 34 | case 'TIME_END': { 35 | const timing = state.timings[action.name] 36 | 37 | const end = performance.now() || +new Date() 38 | const { start } = timing 39 | 40 | const time = end - start 41 | 42 | return { 43 | ...state, 44 | timings: { 45 | ...state.timings, 46 | [action.name]: { 47 | ...timing, 48 | end, 49 | time 50 | } 51 | } 52 | } 53 | } 54 | 55 | default: { 56 | return state 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Transform/Function.ts: -------------------------------------------------------------------------------- 1 | interface Storage { 2 | name: string 3 | body: string 4 | proto: string 5 | } 6 | 7 | /** 8 | * Serialize a function into JSON 9 | */ 10 | export default { 11 | type: 'Function', 12 | shouldTransform(type: any, obj: any) { 13 | return typeof obj === 'function' 14 | }, 15 | toSerializable(func: Function): Storage { 16 | let body = '' 17 | try { 18 | body = func 19 | .toString() 20 | .substring(body.indexOf('{') + 1, body.lastIndexOf('}')) 21 | } catch (e) {} 22 | 23 | return { 24 | name: func.name, 25 | body, 26 | proto: Object.getPrototypeOf(func).constructor.name 27 | } 28 | }, 29 | fromSerializable(data: Storage) { 30 | try { 31 | const tempFunc = function() {} 32 | 33 | if (typeof data.name === 'string') { 34 | Object.defineProperty(tempFunc, 'name', { 35 | value: data.name, 36 | writable: false 37 | }) 38 | } 39 | 40 | if (typeof data.body === 'string') { 41 | Object.defineProperty(tempFunc, 'body', { 42 | value: data.body, 43 | writable: false 44 | }) 45 | } 46 | 47 | if (typeof data.proto === 'string') { 48 | // @ts-ignore 49 | tempFunc.constructor = { 50 | name: data.proto 51 | } 52 | } 53 | 54 | return tempFunc 55 | } catch (e) { 56 | return data 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Component/message-parsers/Object.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Theme } from '../../definitions/Component' 3 | import { withTheme } from 'emotion-theming' 4 | import { Root } from '../react-inspector/elements' 5 | 6 | import * as Linkify from 'linkifyjs/react' 7 | import { Message } from '../../definitions/Component' 8 | import Inspector from '../react-inspector' 9 | 10 | interface Props { 11 | log: Message 12 | quoted: boolean 13 | theme?: Theme 14 | } 15 | 16 | class ObjectTree extends React.PureComponent { 17 | render() { 18 | const { theme, quoted, log } = this.props 19 | 20 | return log.data.map((message: any, i: number) => { 21 | if (typeof message === 'string') { 22 | const string = 23 | !quoted && message.length ? ( 24 | `${message} ` 25 | ) : ( 26 | 27 | " 28 | 32 | {message} 33 | 34 | " 35 | 36 | ) 37 | 38 | return ( 39 | 40 | {string} 41 | 42 | ) 43 | } 44 | 45 | return 46 | }) 47 | } 48 | } 49 | 50 | export default withTheme(ObjectTree) 51 | -------------------------------------------------------------------------------- /src/Transform/HTML.ts: -------------------------------------------------------------------------------- 1 | // Sandbox HTML elements 2 | const sandbox = document.implementation.createHTMLDocument('sandbox') 3 | 4 | interface Storage { 5 | tagName: string 6 | attributes: { 7 | [attribute: string]: string 8 | } 9 | innerHTML: string 10 | } 11 | 12 | function objectifyAttributes(element: any) { 13 | const data = {} 14 | for (let attribute of element.attributes) { 15 | data[attribute.name] = attribute.value 16 | } 17 | return data 18 | } 19 | 20 | /** 21 | * Serialize a HTML element into JSON 22 | */ 23 | export default { 24 | type: 'HTMLElement', 25 | shouldTransform(type: any, obj: any) { 26 | return ( 27 | obj && 28 | obj.children && 29 | typeof obj.innerHTML === 'string' && 30 | typeof obj.tagName === 'string' 31 | ) 32 | }, 33 | toSerializable(element: HTMLElement) { 34 | return { 35 | tagName: element.tagName.toLowerCase(), 36 | attributes: objectifyAttributes(element), 37 | innerHTML: element.innerHTML 38 | } as Storage 39 | }, 40 | fromSerializable(data: Storage) { 41 | try { 42 | const element = sandbox.createElement(data.tagName) as HTMLElement 43 | element.innerHTML = data.innerHTML 44 | for (let attribute of Object.keys(data.attributes)) { 45 | try { 46 | element.setAttribute(attribute, data.attributes[attribute]) 47 | } catch (e) {} 48 | } 49 | return element 50 | } catch (e) { 51 | return data 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Component/message-parsers/Error.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Message } from '../../definitions/Component' 3 | import * as Linkify from 'linkifyjs/react' 4 | 5 | interface Props { 6 | log: Message 7 | } 8 | 9 | function splitMessage(message: string): string { 10 | const breakIndex = message.indexOf('\n') 11 | // consider that there can be line without a break 12 | if (breakIndex === -1) { 13 | return message 14 | } 15 | return message.substr(0, breakIndex) 16 | } 17 | 18 | class ErrorPanel extends React.PureComponent { 19 | render() { 20 | const { log } = this.props 21 | /* This checks for error logTypes and shortens the message in the console by wrapping 22 | it a
tag and putting the first line in a tag and the other lines 23 | follow after that. This creates a nice collapsible error message */ 24 | let otherErrorLines 25 | const msgLine = log.data.join(' ') 26 | const firstLine = splitMessage(msgLine) 27 | const msgArray = msgLine.split('\n') 28 | if (msgArray.length > 1) { 29 | otherErrorLines = msgArray.slice(1) 30 | } 31 | 32 | if (!otherErrorLines) { 33 | return {log.data.join(' ')} 34 | } 35 | 36 | return ( 37 |
38 | 39 | {firstLine} 40 | 41 | {otherErrorLines.join('\n\r')} 42 |
43 | ) 44 | } 45 | } 46 | 47 | export default ErrorPanel 48 | -------------------------------------------------------------------------------- /src/Hook/__tests__/__snapshots__/Hook.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`correctly encodes Functions 1`] = ` 4 | Array [ 5 | [Function], 6 | ] 7 | `; 8 | 9 | exports[`correctly encodes a HTMLElement 1`] = ` 10 | Array [ 11 | 12 | 13 | 69 | 70 | 71 | , 72 | "function": [Function], 73 | "nested": Array [ 74 | Array [ 75 | Array [ 76 | Promise {}, 77 | ], 78 | ], 79 | ], 80 | "recursive": [Circular], 81 | }, 82 | ] 83 | `; 84 | -------------------------------------------------------------------------------- /demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render } from 'react-dom' 3 | import update from 'immutability-helper' 4 | import { Hook, Console, Decode } from '../../src' 5 | 6 | const iframe = document.createElement('iframe') 7 | iframe.src = './iframe.html' 8 | document.body.appendChild(iframe) 9 | 10 | class App extends React.Component { 11 | state = { 12 | logs: [ 13 | { 14 | method: 'result', 15 | data: ['Result'] 16 | }, 17 | { 18 | method: 'command', 19 | data: ['Command'] 20 | } 21 | ] as any[], 22 | filter: [], 23 | searchKeywords: '' 24 | } 25 | 26 | componentDidMount() { 27 | Hook(iframe.contentWindow.console, log => { 28 | const decoded = Decode(log) 29 | this.setState(state => update(state, { logs: { $push: [decoded] } })) 30 | }) 31 | } 32 | 33 | switch = () => { 34 | const filter = this.state.filter.length === 0 ? ['log'] : [] 35 | this.setState({ 36 | filter 37 | }) 38 | } 39 | 40 | handleKeywordsChange = ({ target: { value: searchKeywords } }) => { 41 | this.setState({ searchKeywords }) 42 | } 43 | 44 | render() { 45 | return ( 46 |
47 |
48 | 49 | 50 |
51 | 52 | 58 |
59 | ) 60 | } 61 | } 62 | 63 | render(, document.querySelector('#demo')) 64 | -------------------------------------------------------------------------------- /demo/public/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | demo iframe 3 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/Hook/parse/__tests__/Parse.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash' 2 | import Parse from '..' 3 | 4 | it('asserts values', () => { 5 | expect(Parse('assert', [2 > 1], 'assert-true')).toBe(false) 6 | expect(Parse('assert', [1 > 2], 'assert-false')).toMatchSnapshot( 7 | 'assertion failed' 8 | ) 9 | }) 10 | 11 | describe('count', () => { 12 | it('counts with label', () => { 13 | let final 14 | 15 | _.times(10, () => { 16 | final = Parse('count', ['count-10']) 17 | }) 18 | 19 | expect(final && final.data[0]).toBe('count-10: 10') 20 | }) 21 | 22 | it('counts with default label', () => { 23 | let final 24 | 25 | _.times(10, () => { 26 | final = Parse('count', []) 27 | }) 28 | 29 | expect(final && final.data[0]).toBe('default: 10') 30 | }) 31 | }) 32 | 33 | describe('time', () => { 34 | it('profile time with label', () => { 35 | Parse('time', ['timer-test']) 36 | 37 | setTimeout(() => { 38 | const result = Parse('timeEnd', ['timer-test'], 'timer-result') 39 | expect( 40 | result && +result.data[0].replace(/[^0-9]/g, '') > 100 41 | ).toBeTruthy() 42 | }, 100) 43 | }) 44 | 45 | it('non existent label', () => { 46 | Parse('time', ['timer-test']) 47 | 48 | const failure = Parse('timeEnd', ['nonExistent'], 'timer-fail') 49 | expect(failure).toMatchSnapshot('non existent timer') 50 | }) 51 | 52 | it('profile time with default label', () => { 53 | Parse('time', []) 54 | 55 | const result = Parse('timeEnd', [], 'timer-result') 56 | expect(result && result.data[0].match(/^default: \d+\.\d+ms$/)).toBeTruthy() 57 | }) 58 | }) 59 | 60 | it('records errors', () => { 61 | const result = Parse('error', [new Error('one')], 'errors') 62 | 63 | expect(result && result.data[0]).toContain('Error: one') 64 | }) 65 | -------------------------------------------------------------------------------- /src/Hook/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HookedConsole, 3 | Callback, 4 | Storage, 5 | Methods as ConsoleMethods, 6 | Message 7 | } from '../definitions/Console' 8 | import Methods from '../definitions/Methods' 9 | 10 | import Parse from './parse' 11 | import Unhook from '../Unhook' 12 | import { Encode } from '../Transform' 13 | // import Construct from './construct' 14 | 15 | /** 16 | * Hook a console constructor and forward messages to a callback 17 | * @argument console The Console constructor to Hook 18 | * @argument callback The callback to be called once a message is logged 19 | */ 20 | export default function Hook( 21 | console: Console, 22 | callback: Callback, 23 | encode = true 24 | ) { 25 | const TargetConsole = console as HookedConsole 26 | const Storage: Storage = { 27 | pointers: {}, 28 | src: { 29 | npm: 'https://npmjs.com/package/console-feed', 30 | github: 'https://github.com/samdenty99/console-feed' 31 | } 32 | } 33 | 34 | // Override console methods 35 | for (let method of Methods) { 36 | const NativeMethod = TargetConsole[method] 37 | 38 | // Override 39 | TargetConsole[method] = function() { 40 | // Pass back to native method 41 | NativeMethod.apply(this, arguments) 42 | 43 | // Parse arguments and send to transport 44 | const args = [].slice.call(arguments) 45 | 46 | // setTimeout to prevent lag 47 | setTimeout(() => { 48 | const parsed = Parse(method as ConsoleMethods, args) 49 | if (parsed) { 50 | let encoded: Message = parsed as Message 51 | if (encode) { 52 | encoded = Encode(parsed) as Message 53 | } 54 | callback(encoded, parsed) 55 | } 56 | }) 57 | } 58 | 59 | // Store native methods 60 | Storage.pointers[method] = NativeMethod 61 | } 62 | 63 | TargetConsole.feed = Storage 64 | 65 | return TargetConsole 66 | } 67 | -------------------------------------------------------------------------------- /src/Component/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ThemeProvider } from 'emotion-theming' 3 | import { Props } from '../definitions/Component' 4 | import Styles from './theme/default' 5 | 6 | import { Root } from './elements' 7 | import Message from './Message' 8 | 9 | // https://stackoverflow.com/a/48254637/4089357 10 | const customStringify = function(v) { 11 | const cache = new Set() 12 | return JSON.stringify(v, function(key, value) { 13 | if (typeof value === 'object' && value !== null) { 14 | if (cache.has(value)) { 15 | // Circular reference found, discard key 16 | return 17 | } 18 | // Store value in our set 19 | cache.add(value) 20 | } 21 | return value 22 | }) 23 | } 24 | 25 | class Console extends React.PureComponent { 26 | theme = () => ({ 27 | variant: this.props.variant || 'light', 28 | styles: { 29 | ...Styles(this.props), 30 | ...this.props.styles 31 | } 32 | }) 33 | 34 | render() { 35 | let { filter = [], logs = [], searchKeywords, logFilter } = this.props 36 | 37 | const regex = new RegExp(searchKeywords) 38 | 39 | const filterFun = logFilter 40 | ? logFilter 41 | : log => regex.test(customStringify(log)) 42 | 43 | // @ts-ignore 44 | logs = logs.filter(filterFun) 45 | 46 | return ( 47 | 48 | 49 | {logs.map((log, i) => { 50 | // If the filter is defined and doesn't include the method 51 | const filtered = 52 | filter.length !== 0 && 53 | log.method && 54 | filter.indexOf(log.method) === -1 55 | 56 | return filtered ? null : ( 57 | 58 | ) 59 | })} 60 | 61 | 62 | ) 63 | } 64 | } 65 | 66 | export default Console 67 | -------------------------------------------------------------------------------- /src/Hook/__tests__/Hook.spec.tsx: -------------------------------------------------------------------------------- 1 | import Hook from '..' 2 | import console from './console' 3 | import Log from './Log' 4 | import { Decode } from '../..' 5 | 6 | it('hooks the console', () => { 7 | Hook(console, log => { 8 | console.logs.push(log) 9 | }) 10 | expect(console.feed).toBeTruthy() 11 | }) 12 | 13 | it('forwards log events', async () => { 14 | const result = await Log('log', 'test') 15 | expect(result).toBeTruthy() 16 | }) 17 | 18 | it('decodes messages', () => { 19 | const decoded = Decode(console.logs[0]) 20 | expect(decoded.method).toEqual('log') 21 | expect(decoded.data).toMatchSnapshot() 22 | }) 23 | 24 | it('correctly encodes a HTMLElement', async () => { 25 | const result = await Log('warn', document.documentElement) 26 | expect(result).toBeTruthy() 27 | 28 | const decoded = Decode(result) 29 | expect(decoded.method).toEqual('warn') 30 | expect(decoded.data).toMatchSnapshot() 31 | }) 32 | 33 | it('correctly encodes Functions', async () => { 34 | // prettier-ignore 35 | const result = await Log('error', function myFunc() { /* body */ }) 36 | 37 | const decoded = Decode(result) 38 | expect(decoded.method).toEqual('error') 39 | expect(decoded.data).toMatchSnapshot() 40 | }) 41 | 42 | it('correctly encodes nested values', async () => { 43 | const input = { 44 | function: function myFunc() {}, 45 | document: document.documentElement, 46 | nested: [[[new Promise(() => {})]]], 47 | recursive: null 48 | } 49 | input.recursive = input 50 | 51 | const result = await Log('debug', input) 52 | 53 | const decoded = Decode(result) 54 | expect(decoded.method).toEqual('debug') 55 | expect(decoded.data).toMatchSnapshot() 56 | }) 57 | 58 | it('disables encoding with a flag', async () => { 59 | Hook( 60 | console, 61 | log => { 62 | console.logs.push(log) 63 | }, 64 | false 65 | ) 66 | const input = { 67 | function: function myFunc() {}, 68 | document: document.documentElement, 69 | nested: [[[new Promise(() => {})]]], 70 | recursive: null 71 | } 72 | input.recursive = input 73 | 74 | const result: any = await Log('debug', input) 75 | 76 | expect(result.data).toMatchSnapshot() 77 | }) 78 | -------------------------------------------------------------------------------- /src/Component/elements.tsx: -------------------------------------------------------------------------------- 1 | import styled from './theme' 2 | import { Context } from '../definitions/Component' 3 | 4 | /** 5 | * Return themed log-method style 6 | * @param style The style 7 | * @param type The method 8 | */ 9 | const Themed = (style: string, method: string, styles) => 10 | styles[`LOG_${method.toUpperCase()}_${style.toUpperCase()}`] || 11 | styles[`LOG_${style.toUpperCase()}`] 12 | 13 | interface Props { 14 | theme: Context 15 | } 16 | 17 | /** 18 | * console-feed 19 | */ 20 | export const Root = styled('div')({ 21 | wordBreak: 'break-word' 22 | }) 23 | 24 | /** 25 | * console-message 26 | */ 27 | export const Message = styled('div')( 28 | ({ theme: { styles, method } }: Props) => ({ 29 | position: 'relative', 30 | display: 'flex', 31 | color: Themed('color', method, styles), 32 | backgroundColor: Themed('background', method, styles), 33 | borderTop: `1px solid ${Themed('border', method, styles)}`, 34 | borderBottom: `1px solid ${Themed('border', method, styles)}`, 35 | marginTop: -1, 36 | marginBottom: +/^warn|error$/.test(method), 37 | paddingLeft: 10, 38 | boxSizing: 'border-box', 39 | '& *': { 40 | verticalAlign: 'top', 41 | boxSizing: 'border-box', 42 | fontFamily: styles.BASE_FONT_FAMILY, 43 | whiteSpace: 'pre-wrap', 44 | fontSize: styles.BASE_FONT_SIZE 45 | }, 46 | '& a': { 47 | color: 'rgb(177, 177, 177)' 48 | } 49 | }) 50 | ) 51 | 52 | /** 53 | * message-icon 54 | */ 55 | export const Icon = styled('div')( 56 | ({ theme: { styles, method } }: Props) => ({ 57 | width: styles.LOG_ICON_WIDTH, 58 | height: styles.LOG_ICON_HEIGHT, 59 | backgroundImage: Themed('icon', method, styles), 60 | backgroundRepeat: 'no-repeat', 61 | backgroundSize: styles.LOG_ICON_BACKGROUND_SIZE, 62 | backgroundPosition: '50% 50%' 63 | }) 64 | ) 65 | 66 | /** 67 | * console-content 68 | */ 69 | export const Content = styled('div')( 70 | ({ theme: { styles, method } }: Props) => ({ 71 | clear: 'right', 72 | position: 'relative', 73 | padding: styles.PADDING, 74 | marginLeft: 15, 75 | minHeight: 18, 76 | flex: 'auto', 77 | width: 'calc(100% - 15px)' 78 | }) 79 | ) 80 | -------------------------------------------------------------------------------- /src/definitions/Styles.d.ts: -------------------------------------------------------------------------------- 1 | export interface Styles { 2 | // Log icons 3 | LOG_ICON_WIDTH?: string | number 4 | LOG_ICON_HEIGHT?: string | number 5 | 6 | // Log colors 7 | // LOG_ICON => CSS background-image property 8 | LOG_COLOR?: string 9 | LOG_ICON?: string 10 | LOG_BACKGROUND?: string 11 | LOG_ICON_BACKGROUND_SIZE?: string 12 | LOG_BORDER?: string 13 | 14 | LOG_INFO_COLOR?: string 15 | LOG_INFO_ICON?: string 16 | LOG_INFO_BACKGROUND?: string 17 | LOG_INFO_BORDER?: string 18 | 19 | LOG_COMMAND_COLOR?: string 20 | LOG_COMMAND_ICON?: string 21 | LOG_COMMAND_BACKGROUND?: string 22 | LOG_COMMAND_BORDER?: string 23 | 24 | LOG_RESULT_COLOR?: string 25 | LOG_RESULT_ICON?: string 26 | LOG_RESULT_BACKGROUND?: string 27 | LOG_RESULT_BORDER?: string 28 | 29 | LOG_WARN_COLOR?: string 30 | LOG_WARN_ICON?: string 31 | LOG_WARN_BACKGROUND?: string 32 | LOG_WARN_BORDER?: string 33 | 34 | LOG_ERROR_COLOR?: string 35 | LOG_ERROR_ICON?: string 36 | LOG_ERROR_BACKGROUND?: string 37 | LOG_ERROR_BORDER?: string 38 | 39 | // Fonts 40 | BASE_FONT_FAMILY?: any 41 | BASE_FONT_SIZE?: any 42 | BASE_LINE_HEIGHT?: any 43 | 44 | // Spacing 45 | PADDING?: string 46 | 47 | // react-inspector 48 | BASE_BACKGROUND_COLOR?: any 49 | BASE_COLOR?: any 50 | 51 | OBJECT_NAME_COLOR?: any 52 | OBJECT_VALUE_NULL_COLOR?: any 53 | OBJECT_VALUE_UNDEFINED_COLOR?: any 54 | OBJECT_VALUE_REGEXP_COLOR?: any 55 | OBJECT_VALUE_STRING_COLOR?: any 56 | OBJECT_VALUE_SYMBOL_COLOR?: any 57 | OBJECT_VALUE_NUMBER_COLOR?: any 58 | OBJECT_VALUE_BOOLEAN_COLOR?: any 59 | OBJECT_VALUE_FUNCTION_KEYWORD_COLOR?: any 60 | 61 | HTML_TAG_COLOR?: any 62 | HTML_TAGNAME_COLOR?: any 63 | HTML_TAGNAME_TEXT_TRANSFORM?: any 64 | HTML_ATTRIBUTE_NAME_COLOR?: any 65 | HTML_ATTRIBUTE_VALUE_COLOR?: any 66 | HTML_COMMENT_COLOR?: any 67 | HTML_DOCTYPE_COLOR?: any 68 | 69 | ARROW_COLOR?: any 70 | ARROW_MARGIN_RIGHT?: any 71 | ARROW_FONT_SIZE?: any 72 | 73 | TREENODE_FONT_FAMILY?: any 74 | TREENODE_FONT_SIZE?: any 75 | TREENODE_LINE_HEIGHT?: any 76 | TREENODE_PADDING_LEFT?: any 77 | 78 | TABLE_BORDER_COLOR?: any 79 | TABLE_TH_BACKGROUND_COLOR?: any 80 | TABLE_TH_HOVER_COLOR?: any 81 | TABLE_SORT_ICON_COLOR?: any 82 | TABLE_DATA_BACKGROUND_IMAGE?: any 83 | TABLE_DATA_BACKGROUND_SIZE?: any 84 | 85 | [style: string]: any 86 | } 87 | -------------------------------------------------------------------------------- /src/Hook/parse/index.ts: -------------------------------------------------------------------------------- 1 | import { Methods } from '../../definitions/Console' 2 | import { Payload } from '../../definitions/Payload' 3 | import GUID from './GUID' 4 | 5 | import * as Timing from './methods/timing' 6 | import * as Count from './methods/count' 7 | import * as Assert from './methods/assert' 8 | 9 | /** 10 | * Parses a console log and converts it to a special Log object 11 | * @argument method The console method to parse 12 | * @argument data The arguments passed to the console method 13 | */ 14 | function Parse( 15 | method: Methods, 16 | data: any[], 17 | staticID?: string 18 | ): Payload | false { 19 | // Create an ID 20 | const id = staticID || GUID() 21 | 22 | // Parse the methods 23 | switch (method) { 24 | case 'clear': { 25 | return { 26 | method, 27 | id 28 | } 29 | } 30 | 31 | case 'count': { 32 | const label = typeof data[0] === 'string' ? data[0] : 'default' 33 | if (!label) return false 34 | 35 | return { 36 | ...Count.increment(label), 37 | id 38 | } 39 | } 40 | 41 | case 'time': 42 | case 'timeEnd': { 43 | const label = typeof data[0] === 'string' ? data[0] : 'default' 44 | if (!label) return false 45 | 46 | if (method === 'time') { 47 | Timing.start(label) 48 | return false 49 | } 50 | 51 | return { 52 | ...Timing.stop(label), 53 | id 54 | } 55 | } 56 | 57 | case 'assert': { 58 | const valid = data.length !== 0 59 | 60 | if (valid) { 61 | const assertion = Assert.test(data[0], ...data.slice(1)) 62 | if (assertion) { 63 | return { 64 | ...assertion, 65 | id 66 | } 67 | } 68 | } 69 | 70 | return false 71 | } 72 | 73 | case 'error': { 74 | const errors = data.map(error => { 75 | try { 76 | return error.stack || error 77 | } catch (e) { 78 | return error 79 | } 80 | }) 81 | 82 | return { 83 | method, 84 | id, 85 | data: errors 86 | } 87 | } 88 | 89 | default: { 90 | return { 91 | method, 92 | id, 93 | data 94 | } 95 | } 96 | } 97 | } 98 | 99 | export default Parse 100 | -------------------------------------------------------------------------------- /src/Component/Message.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { MessageProps } from '../definitions/Component' 3 | import { ThemeProvider } from 'emotion-theming' 4 | 5 | import { Message, Icon, Content } from './elements' 6 | 7 | import Formatted from './message-parsers/Formatted' 8 | import ObjectTree from './message-parsers/Object' 9 | import ErrorPanel from './message-parsers/Error' 10 | 11 | class ConsoleMessage extends React.PureComponent { 12 | theme = (theme) => ({ 13 | ...theme, 14 | method: this.props.log.method 15 | }) 16 | 17 | render() { 18 | const { log } = this.props 19 | return ( 20 | 21 | 22 | 23 | {this.getNode()} 24 | 25 | 26 | ) 27 | } 28 | 29 | getNode() { 30 | const { log } = this.props 31 | 32 | // Error handling 33 | const error = this.typeCheck(log) 34 | if (error) return error 35 | 36 | // Chrome formatting 37 | if ( 38 | log.data.length > 0 && 39 | typeof log.data[0] === 'string' && 40 | log.data[0].indexOf('%') > -1 41 | ) { 42 | return 43 | } 44 | 45 | // Error panel 46 | if ( 47 | log.data.every((message) => typeof message === 'string') && 48 | log.method === 'error' 49 | ) { 50 | return 51 | } 52 | 53 | // Normal inspector 54 | const quoted = typeof log.data[0] !== 'string' 55 | return 56 | } 57 | 58 | typeCheck(log: any) { 59 | if (!log) { 60 | return ( 61 | 69 | ) 70 | } else if (!(log.data instanceof Array)) { 71 | return ( 72 | 80 | ) 81 | } 82 | return false 83 | } 84 | } 85 | 86 | export default ConsoleMessage 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console-feed", 3 | "version": "2.8.11", 4 | "main": "lib/index.js", 5 | "description": "A React component that displays console logs from the current page, an iframe or transported across a server", 6 | "scripts": { 7 | "build": "tsc -p ./tsconfig.build.json", 8 | "clean": "nwb clean-module && nwb clean-demo", 9 | "start": "nwb serve-react-demo", 10 | "release": "yarn test && yarn clean && yarn build && yarn publish && yarn clean", 11 | "precommit": "pretty-quick --staged", 12 | "test": "jest --verbose", 13 | "test:coverage": "jest --coverage", 14 | "test:watch": "jest --watch" 15 | }, 16 | "dependencies": { 17 | "emotion": "^9.1.1", 18 | "emotion-theming": "^9.0.0", 19 | "linkifyjs": "^2.1.6", 20 | "react-emotion": "^9.1.1", 21 | "react-inspector": "^2.2.2" 22 | }, 23 | "devDependencies": { 24 | "@types/enzyme": "^3.1.9", 25 | "@types/jest": "^22.2.3", 26 | "@types/react": "16.0.38", 27 | "@types/react-dom": "16.0.4", 28 | "enzyme": "^3.3.0", 29 | "enzyme-adapter-react-16": "^1.1.1", 30 | "husky": "^0.14.3", 31 | "immutability-helper": "^2.6.6", 32 | "jest": "^22.4.3", 33 | "lodash": "^4.17.5", 34 | "nwb": "0.21.x", 35 | "prettier": "^1.13.7", 36 | "pretty-quick": "^1.6.0", 37 | "react": "^16.3.1", 38 | "react-dom": "^16.3.1", 39 | "ts-jest": "^22.4.2", 40 | "ts-loader": "^3.5.0", 41 | "typescript": "^2.8.1" 42 | }, 43 | "jest": { 44 | "setupTestFrameworkScriptFile": "./scripts/test.js", 45 | "moduleFileExtensions": [ 46 | "ts", 47 | "tsx", 48 | "js" 49 | ], 50 | "transform": { 51 | "^.+\\.tsx?$": "ts-jest" 52 | }, 53 | "testMatch": [ 54 | "**/__tests__/*.spec.(ts|tsx|js)" 55 | ] 56 | }, 57 | "peerDependencies": { 58 | "react": "^15.x || ^16.x" 59 | }, 60 | "files": [ 61 | "es", 62 | "lib", 63 | "umd" 64 | ], 65 | "keywords": [ 66 | "devtools", 67 | "inspector", 68 | "object", 69 | "object-inspector", 70 | "react", 71 | "react-component", 72 | "reactjs", 73 | "table", 74 | "table-inspector", 75 | "table-view", 76 | "tableview", 77 | "tree", 78 | "tree-view", 79 | "treeview", 80 | "ui", 81 | "view" 82 | ], 83 | "homepage": "https://github.com/samdenty99/console-feed", 84 | "repository": { 85 | "type": "git", 86 | "url": "https://github.com/samdenty99/console-feed.git" 87 | }, 88 | "bugs": { 89 | "url": "https://github.com/samdenty99/console-feed/issues" 90 | }, 91 | "author": "Sam Denty (http://github.com/samdenty99)", 92 | "license": "MIT" 93 | } 94 | -------------------------------------------------------------------------------- /src/Component/devtools-parser/format-message.ts: -------------------------------------------------------------------------------- 1 | import { String as StringUtils } from './string-utils' 2 | 3 | function createAppend(s: string) { 4 | const container = document.createDocumentFragment() 5 | container.appendChild(document.createTextNode(s)) 6 | 7 | return container 8 | } 9 | 10 | /** 11 | * @param {string} format 12 | * @param {!Array.} parameters 13 | * @param {!Element} formattedResult 14 | */ 15 | export default function formatWithSubstitutionString( 16 | format: any, 17 | parameters: any, 18 | formattedResult: any 19 | ) { 20 | const formatters: any = {} 21 | 22 | function stringFormatter(obj: any) { 23 | if (typeof obj !== 'string') { 24 | return '' 25 | } 26 | 27 | return String(obj) 28 | } 29 | 30 | function floatFormatter(obj: any) { 31 | if (typeof obj !== 'number') return 'NaN' 32 | return obj 33 | } 34 | 35 | function integerFormatter(obj: any) { 36 | if (typeof obj !== 'number') return 'NaN' 37 | return Math.floor(obj) 38 | } 39 | 40 | let currentStyle: any = null 41 | function styleFormatter(obj: any) { 42 | currentStyle = {} 43 | const buffer = document.createElement('span') 44 | buffer.setAttribute('style', obj) 45 | for (let i = 0; i < buffer.style.length; i++) { 46 | const property = buffer.style[i] 47 | if (isWhitelistedProperty(property)) 48 | currentStyle[property] = buffer.style[property] 49 | } 50 | } 51 | 52 | function isWhitelistedProperty(property: string) { 53 | const prefixes = [ 54 | 'background', 55 | 'border', 56 | 'color', 57 | 'font', 58 | 'line', 59 | 'margin', 60 | 'padding', 61 | 'text', 62 | '-webkit-background', 63 | '-webkit-border', 64 | '-webkit-font', 65 | '-webkit-margin', 66 | '-webkit-padding', 67 | '-webkit-text' 68 | ] 69 | for (let i = 0; i < prefixes.length; i++) { 70 | if (property.startsWith(prefixes[i])) return true 71 | } 72 | return false 73 | } 74 | 75 | formatters.s = stringFormatter 76 | formatters.f = floatFormatter 77 | // Firebug allows both %i and %d for formatting integers. 78 | formatters.i = integerFormatter 79 | formatters.d = integerFormatter 80 | 81 | // Firebug uses %c for styling the message. 82 | formatters.c = styleFormatter 83 | 84 | function append(a: any, b: any) { 85 | if (b instanceof Node) { 86 | a.appendChild(b) 87 | } else if (typeof b !== 'undefined') { 88 | let toAppend: any = createAppend(String(b)) 89 | 90 | if (currentStyle) { 91 | let wrapper = document.createElement('span') 92 | wrapper.appendChild(toAppend) 93 | applyCurrentStyle(wrapper) 94 | for (let i = 0; i < wrapper.children.length; ++i) 95 | applyCurrentStyle(wrapper.children[i]) 96 | toAppend = wrapper 97 | } 98 | a.appendChild(toAppend) 99 | } 100 | return a 101 | } 102 | 103 | /** 104 | * @param {!Element} element 105 | */ 106 | function applyCurrentStyle(element: any) { 107 | for (var key in currentStyle) element.style[key] = currentStyle[key] 108 | } 109 | 110 | // String.format does treat formattedResult like a Builder, result is an object. 111 | return StringUtils.format( 112 | format, 113 | parameters, 114 | formatters, 115 | formattedResult, 116 | append 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/Component/react-inspector/index.tsx: -------------------------------------------------------------------------------- 1 | import { withTheme } from 'emotion-theming' 2 | import * as React from 'react' 3 | import { 4 | DOMInspector, 5 | Inspector, 6 | ObjectLabel, 7 | ObjectName, 8 | ObjectRootLabel, 9 | ObjectValue 10 | } from 'react-inspector' 11 | import ObjectPreview from 'react-inspector/lib/object-inspector/ObjectPreview' 12 | 13 | import { Context } from '../../definitions/Component' 14 | import { Constructor, HTML, Root, Table } from './elements' 15 | 16 | interface Props { 17 | theme?: Context 18 | data: any 19 | } 20 | 21 | const CustomObjectLabel = ({ name, data, isNonenumerable = false }) => ( 22 | 23 | {typeof name === 'string' ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 | : 29 | 30 | 31 | ) 32 | 33 | class CustomInspector extends React.PureComponent { 34 | render() { 35 | const { data, theme } = this.props 36 | const { styles, method } = theme 37 | 38 | const dom = data instanceof HTMLElement 39 | const table = method === 'table' 40 | 41 | return ( 42 | 43 | {table ? ( 44 | 45 | 46 | 47 |
48 | ) : dom ? ( 49 | 50 | 51 | 52 | ) : ( 53 | 58 | )} 59 |
60 | ) 61 | } 62 | 63 | getCustomNode(data: any) { 64 | const { styles } = this.props.theme 65 | const constructor = data && data.constructor ? data.constructor.name : null 66 | 67 | if (constructor === 'Function') 68 | return ( 69 | 70 | 71 | {` {`} 72 | {data.body} 73 | {`}`} 74 | 75 | ) 76 | 77 | if (constructor === 'Promise') 78 | return ( 79 | 80 | Promise {`{`} 81 | {``} 82 | {`}`} 83 | 84 | ) 85 | 86 | if (data instanceof HTMLElement) 87 | return ( 88 | 89 | 90 | 91 | ) 92 | return null 93 | } 94 | 95 | nodeRenderer(props: any) { 96 | let { depth, name, data, isNonenumerable } = props 97 | 98 | // Root 99 | if (depth === 0) { 100 | const customNode = this.getCustomNode(data) 101 | return customNode || 102 | } 103 | 104 | if (name === 'constructor') 105 | return ( 106 | 107 | 112 | 113 | ) 114 | 115 | const customNode = this.getCustomNode(data) 116 | 117 | return customNode ? ( 118 | 119 | 120 | : 121 | {customNode} 122 | 123 | ) : ( 124 | 129 | ) 130 | } 131 | } 132 | 133 | export default withTheme(CustomInspector) 134 | -------------------------------------------------------------------------------- /src/Component/theme/default.ts: -------------------------------------------------------------------------------- 1 | import { chromeDark, chromeLight } from 'react-inspector' 2 | import { Styles } from '../../definitions/Styles' 3 | import { Props } from '../../definitions/Component' 4 | 5 | const styles = (props: Props) => 6 | ({ 7 | ...((props.variant || 'light') === 'light' ? chromeLight : chromeDark), 8 | /** 9 | * General 10 | */ 11 | PADDING: '3px 22px 2px 0', 12 | 13 | /** 14 | * Default log styles 15 | */ 16 | LOG_COLOR: 'rgba(255,255,255,0.9)', 17 | LOG_BACKGROUND: 'transparent', 18 | LOG_BORDER: 'rgba(255,255,255,0.03)', 19 | LOG_ICON_WIDTH: 10, 20 | LOG_ICON_HEIGHT: 18, 21 | LOG_ICON: 'none', 22 | 23 | /** 24 | * Log types 25 | */ 26 | LOG_WARN_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACkSURBVChTbY7BCoJQFERn0Q/3BX1JuxQjsSCXiV8gtCgxhCIrKIRIqKDVzXl5w5cNHBjm6eGinXiAXu5inY2xYm/mbpIh+vcFhLA3sx0athNUhymEsP+10lAEEA17x8o/9wFuNGnYuVlWve0SQl7P0sBu3aq2R1Q/1JzSkYGd29eqNv2wjdnUuvNRciC/N+qe+7gidbA8zyHkOINsvA/sumcOkjcabcBmw2+mMgAAAABJRU5ErkJggg==)`, 27 | LOG_WARN_BACKGROUND: '#332b00', 28 | LOG_WARN_COLOR: '#ffdc9e', 29 | LOG_WARN_BORDER: '#650', 30 | 31 | LOG_ERROR_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADESURBVChTY4CB7ZI8tmfU5E6e01b+DMIgNkgMKg0BR9Vkux6YWPx/bemIgkFiIDmwogOaqrYPzazAEm8DwuGKYGyQHEgNw0VT05Mwib9v3v7/kJEHxiA2TDFIDcNNU4vPMFPACj58/P/v40cwGyYOUsNwy8IZRSFIEUgxskKQGoZrzp4ErQapYbgYHG371M4dLACTQGaD5EBqwD6/FpzQ9dTBE64IhkFiIDmwIhi4mlJqey8o4eR9r8jPIAxig8QgsgwMAFZz1YtGPXgjAAAAAElFTkSuQmCC)`, 32 | LOG_ERROR_BACKGROUND: '#290000', 33 | LOG_ERROR_BORDER: '#5b0000', 34 | LOG_ERROR_COLOR: '#ff8080', 35 | 36 | LOG_DEBUG_ICON: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 459 459'%3e%3cpath fill='%234D88FF' d='M433.5 127.5h-71.4a177.7 177.7 0 0 0-45.9-51L357 35.7 321.3 0l-56.1 56.1c-10.2-2.6-23-5.1-35.7-5.1s-25.5 2.5-35.7 5.1L137.7 0 102 35.7l40.8 40.8a177.7 177.7 0 0 0-45.9 51H25.5v51H79c-2.5 7.7-2.5 17.9-2.5 25.5v25.5h-51v51h51V306a88 88 0 0 0 2.5 25.5H25.5v51h71.4A152.2 152.2 0 0 0 229.5 459c56.1 0 107.1-30.6 132.6-76.5h71.4v-51H380c2.5-7.7 2.5-17.9 2.5-25.5v-25.5h51v-51h-51V204c0-7.7 0-17.9-2.5-25.5h53.5v-51zm-153 204h-102v-51h102v51zm0-102h-102v-51h102v51z'/%3e%3c/svg%3e")`, 37 | LOG_DEBUG_BACKGROUND: '', 38 | LOG_DEBUG_BORDER: '', 39 | LOG_DEBUG_COLOR: '#4D88FF', 40 | 41 | LOG_COMMAND_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABaSURBVChTY6AtmDx5cvnUqVP1oFzsoL+/XwCo8DEQv584caIVVBg7mDBhghxQ4Y2+vr6vU6ZM8YAKYwdA00SB+CxQ8S+g4jCoMCYgSiFRVpPkGaAiHMHDwAAA5Ko+F4/l6+MAAAAASUVORK5CYII=)`, 42 | LOG_RESULT_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABpSURBVChTY6A92LNnj96uXbvKoVzsYMeOHVbbt29/D1T4eP/+/QJQYVSwe/duD6CCr0B8A8iWgwqjAqBk2NatW38B6bPbtm0TBYkBFbsA+c9ANFgRCBCtEASAAoSthgGiPAMD2IOHgQEA521bM7uG52wAAAAASUVORK5CYII=)`, 43 | LOG_INFO_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADISURBVChTY4ABp/AztmZBZ07qe538rO114rOa8+GTskYHbKHSEOARd6nLIOTsf61gIA46U6kVePYQiK3uc/K/hPG+LrCi8IyrtkZh5yCKgk/80w46ba0RdGYGhH/2v6rXyf88qtttGVwSLp2ECQLxeiAu1wo6uwpJ7L+o2f6TDA6xZz8jCyqFnuHXCj4djywmZXHoM/EK0azGqhBsNYpngL6VCTnGqRF4xgKo+D5IDO4ZEEAKnjcQBafvqwWf/YoSPDCAP8AZGAC7mLM81zgOTQAAAABJRU5ErkJggg==)`, 44 | 45 | /** 46 | * Fonts 47 | */ 48 | BASE_FONT_FAMILY: 'Consolas, Lucida Console, Courier New, monospace', 49 | BASE_FONT_SIZE: '12px', 50 | 51 | /** 52 | * Other 53 | */ 54 | ARROW_FONT_SIZE: 10, 55 | OBJECT_VALUE_STRING_COLOR: 'rgb(233,63,59)' 56 | } as Styles) 57 | 58 | export default styles 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # console-feed 2 | 3 | [![npm version](https://img.shields.io/npm/v/console-feed.svg?style=flat-square)](https://www.npmjs.com/package/console-feed) 4 | [![CircleCI](https://img.shields.io/circleci/project/github/samdenty99/console-feed.svg?style=flat-square)](https://circleci.com/gh/samdenty99/console-feed) 5 | [![npm downloads](https://img.shields.io/npm/dm/console-feed.svg?style=flat-square)](https://www.npmjs.com/package/console-feed) 6 | [![Demo](https://img.shields.io/badge/CodeSandbox-Demo-yellow.svg?style=flat-square)](https://codesandbox.io/s/rl7pk9w2ym) 7 | 8 | A React component that displays console logs from the current page, an iframe or transported across a server. 9 | 10 | ![Demo](https://user-images.githubusercontent.com/13242392/38513414-1bc32870-3c26-11e8-9a8f-0989d2142b1c.png) 11 | 12 | ## Who's using it 13 | 14 | - [CodeSandbox.io](https://codesandbox.io) 15 | - [Framer](https://www.framer.com) 16 | - [Plunker](https://plnkr.co) 17 | - [P5.js Editor](https://editor.p5js.org) 18 | - [Builder.io](https://builder.io) 19 | 20 | ## Features 21 | 22 | - **Console formatting** - [style and give your logs color](https://stackoverflow.com/questions/22155879/how-do-i-create-formatted-javascript-console-log-messages), and makes links clickable 23 | - **DOM nodes** - easily inspect & expand HTML elements, with syntax highlighting 24 | - **`console.table`** - view your logs in a table format 25 | - **Other console methods**: 26 | - `console.time` - view the time in milliseconds it takes to complete events 27 | - `console.assert` - assert that a statement is truthy 28 | - `console.count` - count how many times something occurs 29 | - **Inbuilt JSON serialization** - Objects, Functions & DOM elements can be encoded / decoded to and from JSON 30 | 31 | ## Install 32 | 33 | ```sh 34 | yarn add console-feed 35 | # or 36 | npm install console-feed 37 | ``` 38 | 39 | ## Basic usage 40 | 41 | [CodeSandbox](https://codesandbox.io/s/rl7pk9w2ym) 42 | 43 | ```js 44 | import React from 'react' 45 | import { Hook, Console, Decode } from 'console-feed' 46 | 47 | class App extends React.Component { 48 | state = { 49 | logs: [] 50 | } 51 | 52 | componentDidMount() { 53 | Hook(window.console, log => { 54 | this.setState(({ logs }) => ({ logs: [...logs, Decode(log)] })) 55 | }) 56 | 57 | console.log(`Hello world!`) 58 | } 59 | 60 | render() { 61 | return ( 62 |
63 | 64 |
65 | ) 66 | } 67 | } 68 | ``` 69 | 70 | ## Props for `` component 71 | 72 | ### `logs: Log[]` 73 | 74 | An array consisting of Log objects. Required 75 | 76 | ### `filter?: Methods[]` 77 | 78 | Filter the logs, only displaying messages of certain methods. 79 | 80 | ### `variant?: 'light' | 'dark'` 81 | 82 | Sets the font color for the component. Default - `light` 83 | 84 | ### `style?: Styles` 85 | 86 | Defines the custom styles to use on the component - see [`Styles.d.ts`](https://github.com/samdenty99/console-feed/blob/master/src/definitions/Styles.d.ts) 87 | 88 | ### `searchKeywords?: string` 89 | 90 | A string value to filter logs 91 | 92 | ### `logFilter?: Function` 93 | 94 | If you want to use a custom log filter function, you can provide your own implementation 95 | 96 | ## Log methods 97 | 98 | Each log has a method assigned to it. The method is used to determine the style of the message and for the `filter` prop. 99 | 100 | ```ts 101 | type Methods = 102 | | 'log' 103 | | 'warn' 104 | | 'error' 105 | | 'info' 106 | | 'debug' 107 | | 'command' 108 | | 'result' 109 | ``` 110 | 111 | ## `Log` object 112 | 113 | A log object consists of the following: 114 | 115 | ```ts 116 | type Logs = Log[] 117 | 118 | interface Log { 119 | // The log method 120 | method: Methods 121 | // The arguments passed to console API 122 | data: any[] 123 | } 124 | ``` 125 | 126 | ## Serialization 127 | 128 | By default when you use the `Hook()` API, logs are serialized so that they will safely work with `JSON.stringify`. In order to restore a log back to format compatible with the `` component, you need to call the `Decode()` method. 129 | 130 | ### Disabling serialization 131 | 132 | If the `Hook` function and the `` component are on the same origin, you can disable serialization to increase performance. 133 | 134 | ```js 135 | Hook( 136 | window.console, 137 | log => { 138 | this.setState(({ logs }) => ({ logs: [...logs, log] })) 139 | }, 140 | false 141 | ) 142 | ``` 143 | 144 | --- 145 | 146 | ## Developing 147 | 148 | To run `console-feed` locally, simply run: 149 | 150 | ```bash 151 | yarn 152 | yarn start 153 | yarn test:watch 154 | ``` 155 | 156 | Head over to `http://localhost:3000` in your browser, and you'll see the demo page come up. After you make changes you'll need to reload, but the jest tests will automatically restart. 157 | -------------------------------------------------------------------------------- /src/Component/devtools-parser/string-utils.ts: -------------------------------------------------------------------------------- 1 | // Taken from the source of chrome devtools: 2 | // https://github.com/ChromeDevTools/devtools-frontend/blob/master/front_end/platform/utilities.js#L805-L1006 3 | 4 | // Copyright 2014 The Chromium Authors. All rights reserved. 5 | // 6 | // Redistribution and use in source and binary forms, with or without 7 | // modification, are permitted provided that the following conditions are 8 | // met: 9 | // 10 | // * Redistributions of source code must retain the above copyright 11 | // notice, this list of conditions and the following disclaimer. 12 | // * Redistributions in binary form must reproduce the above 13 | // copyright notice, this list of conditions and the following disclaimer 14 | // in the documentation and/or other materials provided with the 15 | // distribution. 16 | // * Neither the name of Google Inc. nor the names of its 17 | // contributors may be used to endorse or promote products derived from 18 | // this software without specific prior written permission. 19 | // 20 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | export namespace String { 33 | /** 34 | * @param {string} string 35 | * @param {number} index 36 | * @return {boolean} 37 | */ 38 | function isDigitAt(string: any, index: any) { 39 | var c = string.charCodeAt(index) 40 | return 48 <= c && c <= 57 41 | } 42 | 43 | /** 44 | * @param {string} format 45 | * @param {!Object.} formatters 46 | * @return {!Array.} 47 | */ 48 | function tokenizeFormatString(format: any, formatters: any) { 49 | var tokens: any = [] 50 | var substitutionIndex = 0 51 | 52 | function addStringToken(str: any) { 53 | if (tokens.length && tokens[tokens.length - 1].type === 'string') 54 | tokens[tokens.length - 1].value += str 55 | else tokens.push({ type: 'string', value: str }) 56 | } 57 | 58 | function addSpecifierToken(specifier: any, precision: any, substitutionIndex: any) { 59 | tokens.push({ 60 | type: 'specifier', 61 | specifier: specifier, 62 | precision: precision, 63 | substitutionIndex: substitutionIndex 64 | }) 65 | } 66 | 67 | var index = 0 68 | for ( 69 | var precentIndex = format.indexOf('%', index); 70 | precentIndex !== -1; 71 | precentIndex = format.indexOf('%', index) 72 | ) { 73 | if (format.length === index) 74 | // unescaped % sign at the end of the format string. 75 | break 76 | addStringToken(format.substring(index, precentIndex)) 77 | index = precentIndex + 1 78 | 79 | if (format[index] === '%') { 80 | // %% escape sequence. 81 | addStringToken('%') 82 | ++index 83 | continue 84 | } 85 | 86 | if (isDigitAt(format, index)) { 87 | // The first character is a number, it might be a substitution index. 88 | var number = parseInt(format.substring(index), 10) 89 | while (isDigitAt(format, index)) ++index 90 | 91 | // If the number is greater than zero and ends with a "$", 92 | // then this is a substitution index. 93 | if (number > 0 && format[index] === '$') { 94 | substitutionIndex = number - 1 95 | ++index 96 | } 97 | } 98 | 99 | var precision = -1 100 | if (format[index] === '.') { 101 | // This is a precision specifier. If no digit follows the ".", 102 | // then the precision should be zero. 103 | ++index 104 | precision = parseInt(format.substring(index), 10) 105 | if (isNaN(precision)) precision = 0 106 | 107 | while (isDigitAt(format, index)) ++index 108 | } 109 | 110 | if (!(format[index] in formatters)) { 111 | addStringToken(format.substring(precentIndex, index + 1)) 112 | ++index 113 | continue 114 | } 115 | 116 | addSpecifierToken(format[index], precision, substitutionIndex) 117 | 118 | ++substitutionIndex 119 | ++index 120 | } 121 | 122 | addStringToken(format.substring(index)) 123 | 124 | return tokens 125 | } 126 | 127 | 128 | /** 129 | * @param {string} format 130 | * @param {?ArrayLike} substitutions 131 | * @param {!Object.} formatters 132 | * @param {!T} initialValue 133 | * @param {function(T, Q): T|undefined} append 134 | * @param {!Array.=} tokenizedFormat 135 | * @return {!{formattedResult: T, unusedSubstitutions: ?ArrayLike}}; 136 | * @template T, Q 137 | */ 138 | export function format( 139 | format?: any, 140 | substitutions?: any, 141 | formatters?: any, 142 | initialValue?: any, 143 | append?: any, 144 | tokenizedFormat?: any 145 | ) { 146 | if (!format || !substitutions || !substitutions.length) 147 | return { 148 | formattedResult: append(initialValue, format), 149 | unusedSubstitutions: substitutions 150 | } 151 | 152 | function prettyFunctionName() { 153 | return ( 154 | 'String.format("' + 155 | format + 156 | '", "' + 157 | Array.prototype.join.call(substitutions, '", "') + 158 | '")' 159 | ) 160 | } 161 | 162 | function warn(msg: any) { 163 | console.warn(prettyFunctionName() + ': ' + msg) 164 | } 165 | 166 | function error(msg: any) { 167 | console.error(prettyFunctionName() + ': ' + msg) 168 | } 169 | 170 | var result = initialValue 171 | var tokens = 172 | tokenizedFormat || tokenizeFormatString(format, formatters) 173 | var usedSubstitutionIndexes = {} 174 | 175 | for (var i = 0; i < tokens.length; ++i) { 176 | var token = tokens[i] 177 | 178 | if (token.type === 'string') { 179 | result = append(result, token.value) 180 | continue 181 | } 182 | 183 | if (token.type !== 'specifier') { 184 | error('Unknown token type "' + token.type + '" found.') 185 | continue 186 | } 187 | 188 | if (token.substitutionIndex >= substitutions.length) { 189 | // If there are not enough substitutions for the current substitutionIndex 190 | // just output the format specifier literally and move on. 191 | error( 192 | 'not enough substitution arguments. Had ' + 193 | substitutions.length + 194 | ' but needed ' + 195 | (token.substitutionIndex + 1) + 196 | ', so substitution was skipped.' 197 | ) 198 | result = append( 199 | result, 200 | '%' + (token.precision > -1 ? token.precision : '') + token.specifier 201 | ) 202 | continue 203 | } 204 | 205 | usedSubstitutionIndexes[token.substitutionIndex] = true 206 | 207 | if (!(token.specifier in formatters)) { 208 | // Encountered an unsupported format character, treat as a string. 209 | warn( 210 | 'unsupported format character \u201C' + 211 | token.specifier + 212 | '\u201D. Treating as a string.' 213 | ) 214 | result = append(result, substitutions[token.substitutionIndex]) 215 | continue 216 | } 217 | 218 | result = append( 219 | result, 220 | formatters[token.specifier]( 221 | substitutions[token.substitutionIndex], 222 | token 223 | ) 224 | ) 225 | } 226 | 227 | var unusedSubstitutions = [] as any 228 | for (var i = 0; i < substitutions.length; ++i) { 229 | if (i in usedSubstitutionIndexes) continue 230 | unusedSubstitutions.push(substitutions[i]) 231 | } 232 | 233 | return { formattedResult: result, unusedSubstitutions: unusedSubstitutions } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Transform/replicator/index.ts: -------------------------------------------------------------------------------- 1 | // Const 2 | const TRANSFORMED_TYPE_KEY = '@t' 3 | const CIRCULAR_REF_KEY = '@r' 4 | const KEY_REQUIRE_ESCAPING_RE = /^#*@(t|r)$/ 5 | 6 | const GLOBAL = (function getGlobal() { 7 | // NOTE: see http://www.ecma-international.org/ecma-262/6.0/index.html#sec-performeval step 10 8 | const savedEval = eval 9 | 10 | return savedEval('this') 11 | })() 12 | 13 | const ARRAY_BUFFER_SUPPORTED = typeof ArrayBuffer === 'function' 14 | const MAP_SUPPORTED = typeof Map === 'function' 15 | const SET_SUPPORTED = typeof Set === 'function' 16 | 17 | const TYPED_ARRAY_CTORS = [ 18 | 'Int8Array', 19 | 'Uint8Array', 20 | 'Uint8ClampedArray', 21 | 'Int16Array', 22 | 'Uint16Array', 23 | 'Int32Array', 24 | 'Uint32Array', 25 | 'Float32Array', 26 | 'Float64Array' 27 | ] 28 | 29 | // Saved proto functions 30 | const arrSlice = Array.prototype.slice 31 | 32 | // Default serializer 33 | const JSONSerializer = { 34 | serialize(val: any) { 35 | return JSON.stringify(val) 36 | }, 37 | 38 | deserialize(val: any) { 39 | return JSON.parse(val) 40 | } 41 | } 42 | 43 | // EncodingTransformer 44 | class EncodingTransformer { 45 | references: any 46 | transforms: any 47 | circularCandidates: any 48 | circularCandidatesDescrs: any 49 | circularRefCount: any 50 | 51 | constructor(val: any, transforms: any) { 52 | this.references = val 53 | this.transforms = transforms 54 | this.circularCandidates = [] 55 | this.circularCandidatesDescrs = [] 56 | this.circularRefCount = 0 57 | } 58 | 59 | static _createRefMark(idx: any) { 60 | const obj = Object.create(null) 61 | 62 | obj[CIRCULAR_REF_KEY] = idx 63 | 64 | return obj 65 | } 66 | 67 | _createCircularCandidate(val: any, parent: any, key: any) { 68 | this.circularCandidates.push(val) 69 | this.circularCandidatesDescrs.push({ parent, key, refIdx: -1 }) 70 | } 71 | 72 | _applyTransform(val: any, parent: any, key: any, transform: any) { 73 | const result = Object.create(null) 74 | const serializableVal = transform.toSerializable(val) 75 | 76 | if (typeof serializableVal === 'object') 77 | this._createCircularCandidate(val, parent, key) 78 | 79 | result[TRANSFORMED_TYPE_KEY] = transform.type 80 | result.data = this._handleValue(serializableVal, parent, key) 81 | 82 | return result 83 | } 84 | 85 | _handleArray(arr: any): any { 86 | const result = [] as any 87 | 88 | for (let i = 0; i < arr.length; i++) 89 | result[i] = this._handleValue(arr[i], result, i) 90 | 91 | return result 92 | } 93 | 94 | _handlePlainObject(obj: any) { 95 | const result = Object.create(null) 96 | 97 | for (const key in obj) { 98 | if (obj.hasOwnProperty(key)) { 99 | const resultKey = KEY_REQUIRE_ESCAPING_RE.test(key) ? `#${key}` : key 100 | 101 | result[resultKey] = this._handleValue(obj[key], result, resultKey) 102 | } 103 | } 104 | 105 | const { name } = obj.__proto__.constructor 106 | if (name !== 'Object') { 107 | result.constructor = { name } 108 | } 109 | 110 | return result 111 | } 112 | 113 | _handleObject(obj: any, parent: any, key: any) { 114 | this._createCircularCandidate(obj, parent, key) 115 | 116 | return Array.isArray(obj) 117 | ? this._handleArray(obj) 118 | : this._handlePlainObject(obj) 119 | } 120 | 121 | _ensureCircularReference(obj: any) { 122 | const circularCandidateIdx = this.circularCandidates.indexOf(obj) 123 | 124 | if (circularCandidateIdx > -1) { 125 | const descr = this.circularCandidatesDescrs[circularCandidateIdx] 126 | 127 | if (descr.refIdx === -1) 128 | descr.refIdx = descr.parent ? ++this.circularRefCount : 0 129 | 130 | return EncodingTransformer._createRefMark(descr.refIdx) 131 | } 132 | 133 | return null 134 | } 135 | 136 | _handleValue(val: any, parent: any, key: any) { 137 | const type = typeof val 138 | const isObject = type === 'object' && val !== null 139 | 140 | try { 141 | if (isObject) { 142 | const refMark = this._ensureCircularReference(val) 143 | 144 | if (refMark) return refMark 145 | } 146 | 147 | for (const transform of this.transforms) { 148 | if (transform.shouldTransform(type, val)) 149 | return this._applyTransform(val, parent, key, transform) 150 | } 151 | 152 | if (isObject) return this._handleObject(val, parent, key) 153 | 154 | return val 155 | } catch (e) { 156 | return null 157 | } 158 | } 159 | 160 | transform() { 161 | const references = [this._handleValue(this.references, null, null)] 162 | 163 | for (const descr of this.circularCandidatesDescrs) { 164 | if (descr.refIdx > 0) { 165 | references[descr.refIdx] = descr.parent[descr.key] 166 | descr.parent[descr.key] = EncodingTransformer._createRefMark( 167 | descr.refIdx 168 | ) 169 | } 170 | } 171 | 172 | return references 173 | } 174 | } 175 | 176 | // DecodingTransform 177 | class DecodingTransformer { 178 | references: any 179 | transformMap: any 180 | activeTransformsStack = [] as any 181 | visitedRefs = Object.create(null) 182 | 183 | constructor(references: any, transformsMap: any) { 184 | this.references = references 185 | this.transformMap = transformsMap 186 | } 187 | 188 | _handlePlainObject(obj: any) { 189 | const unescaped = Object.create(null) 190 | 191 | if ('constructor' in obj) { 192 | if (!obj.constructor || typeof obj.constructor.name !== 'string') { 193 | obj.constructor = { 194 | name: 'Object' 195 | } 196 | } 197 | } 198 | 199 | for (const key in obj) { 200 | if (obj.hasOwnProperty(key)) { 201 | this._handleValue(obj[key], obj, key) 202 | 203 | if (KEY_REQUIRE_ESCAPING_RE.test(key)) { 204 | // NOTE: use intermediate object to avoid unescaped and escaped keys interference 205 | // E.g. unescaped "##@t" will be "#@t" which can overwrite escaped "#@t". 206 | unescaped[key.substring(1)] = obj[key] 207 | delete obj[key] 208 | } 209 | } 210 | } 211 | 212 | for (const unsecapedKey in unescaped) 213 | obj[unsecapedKey] = unescaped[unsecapedKey] 214 | } 215 | 216 | _handleTransformedObject(obj: any, parent: any, key: any) { 217 | const transformType = obj[TRANSFORMED_TYPE_KEY] 218 | const transform = this.transformMap[transformType] 219 | 220 | if (!transform) 221 | throw new Error(`Can't find transform for "${transformType}" type.`) 222 | 223 | this.activeTransformsStack.push(obj) 224 | this._handleValue(obj.data, obj, 'data') 225 | this.activeTransformsStack.pop() 226 | 227 | parent[key] = transform.fromSerializable(obj.data) 228 | } 229 | 230 | _handleCircularSelfRefDuringTransform(refIdx: any, parent: any, key: any) { 231 | // NOTE: we've hit a hard case: object reference itself during transformation. 232 | // We can't dereference it since we don't have resulting object yet. And we'll 233 | // not be able to restore reference lately because we will need to traverse 234 | // transformed object again and reference might be unreachable or new object contain 235 | // new circular references. As a workaround we create getter, so once transformation 236 | // complete, dereferenced property will point to correct transformed object. 237 | const references = this.references 238 | 239 | Object.defineProperty(parent, key, { 240 | // @ts-ignore 241 | val: void 0, 242 | configurable: true, 243 | enumerable: true, 244 | 245 | get() { 246 | if (this.val === void 0) this.val = references[refIdx] 247 | 248 | return (this).val 249 | }, 250 | 251 | set(value) { 252 | this.val = value 253 | } 254 | }) 255 | } 256 | 257 | _handleCircularRef(refIdx: any, parent: any, key: any) { 258 | if (this.activeTransformsStack.includes(this.references[refIdx])) 259 | this._handleCircularSelfRefDuringTransform(refIdx, parent, key) 260 | else { 261 | if (!this.visitedRefs[refIdx]) { 262 | this.visitedRefs[refIdx] = true 263 | this._handleValue(this.references[refIdx], this.references, refIdx) 264 | } 265 | 266 | parent[key] = this.references[refIdx] 267 | } 268 | } 269 | 270 | _handleValue(val: any, parent: any, key: any) { 271 | if (typeof val !== 'object' || val === null) return 272 | 273 | const refIdx = val[CIRCULAR_REF_KEY] 274 | 275 | if (refIdx !== void 0) this._handleCircularRef(refIdx, parent, key) 276 | else if (val[TRANSFORMED_TYPE_KEY]) 277 | this._handleTransformedObject(val, parent, key) 278 | else if (Array.isArray(val)) { 279 | for (let i = 0; i < val.length; i++) this._handleValue(val[i], val, i) 280 | } else this._handlePlainObject(val) 281 | } 282 | 283 | transform() { 284 | this.visitedRefs[0] = true 285 | this._handleValue(this.references[0], this.references, 0) 286 | 287 | return this.references[0] 288 | } 289 | } 290 | 291 | // Transforms 292 | const builtInTransforms = [ 293 | { 294 | type: '[[NaN]]', 295 | 296 | shouldTransform(type: any, val: any) { 297 | return type === 'number' && isNaN(val) 298 | }, 299 | 300 | toSerializable() { 301 | return '' 302 | }, 303 | 304 | fromSerializable() { 305 | return NaN 306 | } 307 | }, 308 | 309 | { 310 | type: '[[undefined]]', 311 | 312 | shouldTransform(type: any) { 313 | return type === 'undefined' 314 | }, 315 | 316 | toSerializable() { 317 | return '' 318 | }, 319 | 320 | fromSerializable() { 321 | return void 0 322 | } 323 | }, 324 | { 325 | type: '[[Date]]', 326 | 327 | shouldTransform(type: any, val: any) { 328 | return val instanceof Date 329 | }, 330 | 331 | toSerializable(date: any) { 332 | return date.getTime() 333 | }, 334 | 335 | fromSerializable(val: any) { 336 | const date = new Date() 337 | 338 | date.setTime(val) 339 | return date 340 | } 341 | }, 342 | { 343 | type: '[[RegExp]]', 344 | 345 | shouldTransform(type: any, val: any) { 346 | return val instanceof RegExp 347 | }, 348 | 349 | toSerializable(re: any) { 350 | const result = { 351 | src: re.source, 352 | flags: '' 353 | } 354 | 355 | if (re.global) result.flags += 'g' 356 | 357 | if (re.ignoreCase) result.flags += 'i' 358 | 359 | if (re.multiline) result.flags += 'm' 360 | 361 | return result 362 | }, 363 | 364 | fromSerializable(val: any) { 365 | return new RegExp(val.src, val.flags) 366 | } 367 | }, 368 | 369 | { 370 | type: '[[Error]]', 371 | 372 | shouldTransform(type: any, val: any) { 373 | return val instanceof Error 374 | }, 375 | 376 | toSerializable(err: any) { 377 | return { 378 | name: err.name, 379 | message: err.message, 380 | stack: err.stack 381 | } 382 | }, 383 | 384 | fromSerializable(val: any) { 385 | const Ctor = GLOBAL[val.name] || Error 386 | const err = new Ctor(val.message) 387 | 388 | err.stack = val.stack 389 | return err 390 | } 391 | }, 392 | 393 | { 394 | type: '[[ArrayBuffer]]', 395 | 396 | shouldTransform(type: any, val: any) { 397 | return ARRAY_BUFFER_SUPPORTED && val instanceof ArrayBuffer 398 | }, 399 | 400 | toSerializable(buffer: any) { 401 | const view = new Int8Array(buffer) 402 | 403 | return arrSlice.call(view) 404 | }, 405 | 406 | fromSerializable(val: any) { 407 | if (ARRAY_BUFFER_SUPPORTED) { 408 | const buffer = new ArrayBuffer(val.length) 409 | const view = new Int8Array(buffer) 410 | 411 | view.set(val) 412 | 413 | return buffer 414 | } 415 | 416 | return val 417 | } 418 | }, 419 | 420 | { 421 | type: '[[TypedArray]]', 422 | 423 | shouldTransform(type: any, val: any) { 424 | for (const ctorName of TYPED_ARRAY_CTORS) { 425 | if ( 426 | typeof GLOBAL[ctorName] === 'function' && 427 | val instanceof GLOBAL[ctorName] 428 | ) 429 | return true 430 | } 431 | 432 | return false 433 | }, 434 | 435 | toSerializable(arr: any) { 436 | return { 437 | ctorName: arr.constructor.name, 438 | arr: arrSlice.call(arr) 439 | } 440 | }, 441 | 442 | fromSerializable(val: any) { 443 | return typeof GLOBAL[val.ctorName] === 'function' 444 | ? new GLOBAL[val.ctorName](val.arr) 445 | : val.arr 446 | } 447 | }, 448 | 449 | { 450 | type: '[[Map]]', 451 | 452 | shouldTransform(type: any, val: any) { 453 | return MAP_SUPPORTED && val instanceof Map 454 | }, 455 | 456 | toSerializable(map: any) { 457 | const flattenedKVArr: any = [] 458 | 459 | map.forEach((val: any, key: any) => { 460 | flattenedKVArr.push(key) 461 | flattenedKVArr.push(val) 462 | }) 463 | 464 | return flattenedKVArr 465 | }, 466 | 467 | fromSerializable(val: any) { 468 | if (MAP_SUPPORTED) { 469 | // NOTE: new Map(iterable) is not supported by all browsers 470 | const map = new Map() 471 | 472 | for (var i = 0; i < val.length; i += 2) map.set(val[i], val[i + 1]) 473 | 474 | return map 475 | } 476 | 477 | const kvArr = [] 478 | 479 | // @ts-ignore 480 | for (let j = 0; j < val.length; j += 2) kvArr.push([val[i], val[i + 1]]) 481 | 482 | return kvArr 483 | } 484 | }, 485 | 486 | { 487 | type: '[[Set]]', 488 | 489 | shouldTransform(type: any, val: any) { 490 | return SET_SUPPORTED && val instanceof Set 491 | }, 492 | 493 | toSerializable(set: any) { 494 | const arr: any = [] 495 | 496 | set.forEach((val: any) => { 497 | arr.push(val) 498 | }) 499 | 500 | return arr 501 | }, 502 | 503 | fromSerializable(val: any) { 504 | if (SET_SUPPORTED) { 505 | // NOTE: new Set(iterable) is not supported by all browsers 506 | const set = new Set() 507 | 508 | for (let i = 0; i < val.length; i++) set.add(val[i]) 509 | 510 | return set 511 | } 512 | 513 | return val 514 | } 515 | } 516 | ] 517 | 518 | // Replicator 519 | class Replicator { 520 | transforms = [] as any 521 | transformsMap = Object.create(null) 522 | serializer: any 523 | 524 | constructor(serializer?: any) { 525 | this.serializer = serializer || JSONSerializer 526 | 527 | this.addTransforms(builtInTransforms) 528 | } 529 | 530 | addTransforms(transforms: any) { 531 | transforms = Array.isArray(transforms) ? transforms : [transforms] 532 | 533 | for (const transform of transforms) { 534 | if (this.transformsMap[transform.type]) 535 | throw new Error( 536 | `Transform with type "${transform.type}" was already added.` 537 | ) 538 | 539 | this.transforms.push(transform) 540 | this.transformsMap[transform.type] = transform 541 | } 542 | 543 | return this 544 | } 545 | 546 | removeTransforms(transforms: any) { 547 | transforms = Array.isArray(transforms) ? transforms : [transforms] 548 | 549 | for (const transform of transforms) { 550 | const idx = this.transforms.indexOf(transform) 551 | 552 | if (idx > -1) this.transforms.splice(idx, 1) 553 | 554 | delete this.transformsMap[transform.type] 555 | } 556 | 557 | return this 558 | } 559 | 560 | encode(val: any) { 561 | const transformer = new EncodingTransformer(val, this.transforms) 562 | const references = transformer.transform() 563 | 564 | return this.serializer.serialize(references) 565 | } 566 | 567 | decode(val: any) { 568 | const references = this.serializer.deserialize(val) 569 | const transformer = new DecodingTransformer(references, this.transformsMap) 570 | 571 | return transformer.transform() 572 | } 573 | } 574 | 575 | export default Replicator 576 | --------------------------------------------------------------------------------