├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── demo ├── index.html ├── public │ └── iframe.html ├── src │ ├── index.tsx │ └── main.tsx └── vite.config.ts ├── package.json ├── scripts └── test.js ├── src ├── Component │ ├── Message.tsx │ ├── __tests__ │ │ └── Console.spec.tsx │ ├── devtools-parser │ │ ├── format-message.ts │ │ ├── index.ts │ │ └── string-utils.ts │ ├── elements.tsx │ ├── index.tsx │ ├── message-parsers │ │ ├── Error.tsx │ │ ├── Formatted.tsx │ │ └── Object.tsx │ ├── react-inspector │ │ ├── elements.tsx │ │ ├── index.tsx │ │ └── util.ts │ └── theme │ │ ├── default.ts │ │ └── index.ts ├── Hook │ ├── __tests__ │ │ ├── Hook.spec.tsx │ │ ├── Log.tsx │ │ ├── __snapshots__ │ │ │ └── Hook.spec.tsx.snap │ │ └── console.ts │ ├── construct.ts │ ├── index.ts │ ├── parse │ │ ├── GUID.ts │ │ ├── __tests__ │ │ │ ├── Parse.spec.tsx │ │ │ └── __snapshots__ │ │ │ │ └── Parse.spec.tsx.snap │ │ ├── index.ts │ │ └── methods │ │ │ ├── assert.ts │ │ │ ├── count.ts │ │ │ └── timing.ts │ └── store │ │ ├── actions.ts │ │ ├── dispatch.ts │ │ ├── reducer.ts │ │ └── state.ts ├── Transform │ ├── BigInt.ts │ ├── Function.ts │ ├── HTML.ts │ ├── Map.ts │ ├── arithmetic.ts │ ├── index.ts │ └── replicator │ │ └── index.ts ├── Unhook │ └── index.ts ├── definitions │ ├── Component.d.ts │ ├── ComponentOverrides.d.ts │ ├── Console.d.ts │ ├── Methods.ts │ ├── Payload.d.ts │ ├── Store.d.ts │ └── Styles.d.ts └── index.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.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:10.13.0 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | /.idea 8 | npm-debug.log* 9 | .DS_Store 10 | yarn-error.log 11 | .envrc 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .gitignore 3 | .prettierrc 4 | .vscode 5 | demo 6 | scripts 7 | src 8 | tsconfig.build.json 9 | tsconfig.json 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.13.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": false, 5 | "singleQuote": true, 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Facebook, Inc. and its affiliates. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # console-feed [![Sponsors](https://img.shields.io/github/sponsors/samdenty?label=Sponsors)](https://github.com/sponsors/samdenty) 2 | 3 | [Sponsor this project](https://github.com/sponsors/samdenty) 4 | 5 | [![npm version](https://img.shields.io/npm/v/console-feed.svg?style=flat-square)](https://www.npmjs.com/package/console-feed) 6 | [![npm downloads](https://img.shields.io/npm/dm/console-feed.svg?style=flat-square)](https://www.npmjs.com/package/console-feed) 7 | [![Demo](https://img.shields.io/badge/StackBlitz-Demo-yellow.svg?style=flat-square)](https://stackblitz.com/github/samdenty/console-feed?file=demo%2Fpublic%2Fiframe.html) 8 | 9 | A React component that displays console logs from the current page, an iframe or transported across a server. 10 | 11 | ![Demo](https://user-images.githubusercontent.com/13242392/38513414-1bc32870-3c26-11e8-9a8f-0989d2142b1c.png) 12 | 13 | ## Alternative to `console-feed` 14 | 15 | https://github.com/liriliri/chii supports the embedding the entire Chrome devtools. 16 | 17 | https://github.com/tachibana-shin/vue-console-feed is a fork for Vue.JS 18 | 19 | ## Who's using it 20 | 21 | - [Firebase studio](https://studio.firebase.google.com) 22 | - [Tesla](https://github.com/teslamotors/informed) 23 | - [CodeSandbox.io](https://codesandbox.io) 24 | - [Framer](https://www.framer.com) 25 | - [Plunker](https://plnkr.co) 26 | - [P5.js Editor](https://editor.p5js.org) 27 | - [Builder.io](https://builder.io) 28 | - [Utopia](https://utopia.app/project) 29 | - [facebook/flipper](https://github.com/facebook/flipper) 30 | - [Effector playground](https://share.effector.dev/) 31 | 32 | ## Features 33 | 34 | - **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 35 | - **DOM nodes** - easily inspect & expand HTML elements, with syntax highlighting 36 | - **`console.table`** - view your logs in a table format 37 | - **Other console methods**: 38 | - `console.time` - view the time in milliseconds it takes to complete events 39 | - `console.assert` - assert that a statement is truthy 40 | - `console.count` - count how many times something occurs 41 | - **Inbuilt JSON serialization** - Objects, Functions & DOM elements can be encoded / decoded to and from JSON 42 | 43 | ## Install 44 | 45 | ```sh 46 | yarn add console-feed 47 | # or 48 | npm install console-feed 49 | ``` 50 | 51 | ## Basic usage 52 | 53 | [StackBlitz](https://stackblitz.com/github/samdenty/console-feed?file=demo%2Fpublic%2Fiframe.html) 54 | 55 | ```js 56 | import React from 'react' 57 | import { Hook, Console, Decode } from 'console-feed' 58 | 59 | class App extends React.Component { 60 | state = { 61 | logs: [], 62 | } 63 | 64 | componentDidMount() { 65 | Hook(window.console, (log) => { 66 | this.setState(({ logs }) => ({ logs: [...logs, Decode(log)] })) 67 | }) 68 | 69 | console.log(`Hello world!`) 70 | } 71 | 72 | render() { 73 | return ( 74 |
75 | 76 |
77 | ) 78 | } 79 | } 80 | ``` 81 | 82 | OR with hooks: 83 | 84 | ```js 85 | import React, { useState, useEffect } from 'react' 86 | import { Console, Hook, Unhook } from 'console-feed' 87 | 88 | const LogsContainer = () => { 89 | const [logs, setLogs] = useState([]) 90 | 91 | // run once! 92 | useEffect(() => { 93 | const hookedConsole = Hook( 94 | window.console, 95 | (log) => setLogs((currLogs) => [...currLogs, log]), 96 | false 97 | ) 98 | return () => Unhook(hookedConsole) 99 | }, []) 100 | 101 | return 102 | } 103 | 104 | export { LogsContainer } 105 | ``` 106 | 107 | ## Props for `` component 108 | 109 | ### `logs: Log[]` 110 | 111 | An array consisting of Log objects. Required 112 | 113 | ### `filter?: Methods[]` 114 | 115 | Filter the logs, only displaying messages of certain methods. 116 | 117 | ### `variant?: 'light' | 'dark'` 118 | 119 | Sets the font color for the component. Default - `light` 120 | 121 | ### `styles?: Styles` 122 | 123 | Defines the custom styles to use on the component - see [`Styles.d.ts`](https://github.com/samdenty/console-feed/blob/master/src/definitions/Styles.d.ts) 124 | 125 | ### `searchKeywords?: string` 126 | 127 | A string value to filter logs 128 | 129 | ### `logFilter?: Function` 130 | 131 | If you want to use a custom log filter function, you can provide your own implementation 132 | 133 | ### `components?: ComponentOverrides` 134 | 135 | To fully customize layout and rendering of the console feed, you can provide your own React 136 | components. Currently, only the `Message` component is customizable. 137 | 138 | ## Log methods 139 | 140 | Each log has a method assigned to it. The method is used to determine the style of the message and for the `filter` prop. 141 | 142 | ```ts 143 | type Methods = 144 | | 'log' 145 | | 'debug' 146 | | 'info' 147 | | 'warn' 148 | | 'error' 149 | | 'table' 150 | | 'clear' 151 | | 'time' 152 | | 'timeEnd' 153 | | 'count' 154 | | 'assert' 155 | ``` 156 | 157 | ## `Log` object 158 | 159 | A log object consists of the following: 160 | 161 | ```ts 162 | type Logs = Log[] 163 | 164 | interface Log { 165 | // The log method 166 | method: Methods 167 | // The arguments passed to console API 168 | data: any[] 169 | } 170 | ``` 171 | 172 | ## Serialization 173 | 174 | 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. 175 | 176 | ### Disabling serialization 177 | 178 | If the `Hook` function and the `` component are on the same origin, you can disable serialization to increase performance. 179 | 180 | ```js 181 | Hook( 182 | window.console, 183 | (log) => { 184 | this.setState(({ logs }) => ({ logs: [...logs, log] })) 185 | }, 186 | false 187 | ) 188 | ``` 189 | 190 | ### Limiting serialization 191 | 192 | You can limit the number of keys/elements included when serializing objects/arrays. 193 | 194 | ```js 195 | Hook( 196 | window.console, 197 | (log) => { 198 | this.setState(({ logs }) => ({ logs: [...logs, log] })) 199 | }, 200 | true, 201 | 100 // limit to 100 keys/elements 202 | ) 203 | ``` 204 | 205 | --- 206 | 207 | ## Developing 208 | 209 | To run `console-feed` locally, simply run: 210 | 211 | ```bash 212 | yarn 213 | yarn start 214 | yarn test:watch 215 | ``` 216 | 217 | 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. 218 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite + React + TS 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/public/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | demo iframe 3 | 95 | 96 | -------------------------------------------------------------------------------- /demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import update from 'immutability-helper' 3 | import { Hook, Console, Decode } from '../../src' 4 | import { Message } from '../../src/definitions/Component' 5 | 6 | const iframe = document.createElement('iframe') 7 | iframe.src = './iframe.html' 8 | document.body.appendChild(iframe) 9 | 10 | export class App extends React.Component { 11 | state = { 12 | baseFontSize: 12, 13 | isDarkMode: true, 14 | logs: [ 15 | { 16 | method: 'result', 17 | data: ['Result'], 18 | timestamp: this.getTimestamp(), 19 | }, 20 | { 21 | method: 'command', 22 | data: ['Command'], 23 | timestamp: this.getTimestamp(), 24 | }, 25 | ] as Message[], 26 | filter: [], 27 | searchKeywords: '', 28 | } 29 | 30 | getNumberStringWithWidth(num: Number, width: number) { 31 | const str = num.toString() 32 | if (width > str.length) return '0'.repeat(width - str.length) + str 33 | return str.substr(0, width) 34 | } 35 | 36 | getTimestamp() { 37 | const date = new Date() 38 | const h = this.getNumberStringWithWidth(date.getHours(), 2) 39 | const min = this.getNumberStringWithWidth(date.getMinutes(), 2) 40 | const sec = this.getNumberStringWithWidth(date.getSeconds(), 2) 41 | const ms = this.getNumberStringWithWidth(date.getMilliseconds(), 3) 42 | return `${h}:${min}:${sec}.${ms}` 43 | } 44 | 45 | componentDidMount() { 46 | Hook( 47 | (iframe.contentWindow as any).console, 48 | (log) => { 49 | const decoded = Decode(log) 50 | decoded.timestamp = this.getTimestamp() 51 | this.setState((state) => update(state, { logs: { $push: [decoded] } })) 52 | }, 53 | true, 54 | 100 55 | ) 56 | } 57 | 58 | switch = () => { 59 | const filter = this.state.filter.length === 0 ? ['log'] : [] 60 | this.setState({ 61 | filter, 62 | }) 63 | } 64 | 65 | handleKeywordsChange = ({ target: { value: searchKeywords } }) => { 66 | this.setState({ searchKeywords }) 67 | } 68 | 69 | render() { 70 | const { isDarkMode } = this.state 71 | return ( 72 |
79 |
87 | 88 | 89 | 99 | 111 |
112 | 113 | 122 |
123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'react-dom' 2 | import { App } from './index' 3 | 4 | render(, document.getElementById('root')) 5 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console-feed", 3 | "version": "3.6.0", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "description": "A React component that displays console logs from the current page, an iframe or transported across a server", 7 | "scripts": { 8 | "prepack": "yarn build", 9 | "build": "tsc -p ./tsconfig.build.json --declaration && for file in src/**/*.d.ts; do cp $file ${file/src/lib}; done", 10 | "start": "vite demo", 11 | "release": "yarn test && yarn build && yarn publish", 12 | "precommit": "pretty-quick --staged", 13 | "test": "jest --verbose", 14 | "test:coverage": "jest --coverage", 15 | "test:watch": "jest --watch" 16 | }, 17 | "dependencies": { 18 | "@emotion/core": "^10.0.10", 19 | "@emotion/styled": "^10.0.12", 20 | "emotion-theming": "^10.0.10", 21 | "linkifyjs": "^2.1.6", 22 | "react-inline-center": "1.0.1", 23 | "react-inspector": "^5.1.0" 24 | }, 25 | "devDependencies": { 26 | "@types/enzyme": "^3.1.9", 27 | "@types/jest": "^22.2.3", 28 | "@types/linkifyjs": "2.1.3", 29 | "@types/react": "^16.9.50", 30 | "@types/react-dom": "^16.9.8", 31 | "@vitejs/plugin-react": "^4.2.1", 32 | "enzyme": "^3.3.0", 33 | "enzyme-adapter-react-16": "^1.1.1", 34 | "husky": "^0.14.3", 35 | "immutability-helper": "^2.6.6", 36 | "jest": "^22.4.3", 37 | "lodash": "^4.17.5", 38 | "prettier": "^2.1.2", 39 | "pretty-quick": "^1.6.0", 40 | "react": "^16.14.0", 41 | "react-dom": "^16.14.0", 42 | "ts-jest": "^22.4.2", 43 | "typescript": "^5.4.3", 44 | "vite": "^5.2.7" 45 | }, 46 | "jest": { 47 | "setupTestFrameworkScriptFile": "./scripts/test.js", 48 | "moduleFileExtensions": [ 49 | "ts", 50 | "tsx", 51 | "js" 52 | ], 53 | "transform": { 54 | "^.+\\.tsx?$": "ts-jest" 55 | }, 56 | "testMatch": [ 57 | "**/__tests__/*.spec.(ts|tsx|js)" 58 | ] 59 | }, 60 | "peerDependencies": { 61 | "react": "^15.x || ^16.x || ^17.x || ^18.x || ^19.x" 62 | }, 63 | "files": [ 64 | "es", 65 | "lib", 66 | "umd" 67 | ], 68 | "keywords": [ 69 | "devtools", 70 | "inspector", 71 | "object", 72 | "object-inspector", 73 | "react", 74 | "react-component", 75 | "reactjs", 76 | "table", 77 | "table-inspector", 78 | "table-view", 79 | "tableview", 80 | "tree", 81 | "tree-view", 82 | "treeview", 83 | "ui", 84 | "view" 85 | ], 86 | "homepage": "https://stackblitz.com/github/samdenty/console-feed?file=demo%2Fpublic%2Fiframe.html", 87 | "repository": { 88 | "type": "git", 89 | "url": "https://github.com/samdenty/console-feed.git" 90 | }, 91 | "bugs": { 92 | "url": "https://github.com/samdenty/console-feed/issues" 93 | }, 94 | "author": "Sam Denty (http://github.com/samdenty)", 95 | "license": "MIT" 96 | } 97 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Component/Message.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { MessageProps, Theme } from '../definitions/Component' 3 | import { ThemeProvider } from 'emotion-theming' 4 | import InlineCenter from 'react-inline-center' 5 | 6 | import { 7 | Message, 8 | IconContainer, 9 | Icon, 10 | Content, 11 | AmountIcon, 12 | Timestamp, 13 | } from './elements' 14 | 15 | import Formatted from './message-parsers/Formatted' 16 | import ObjectTree from './message-parsers/Object' 17 | import ErrorPanel from './message-parsers/Error' 18 | 19 | // https://developer.mozilla.org/en-US/docs/Web/API/console#Using_string_substitutions 20 | const reSubstitutions = /(%[coOs])|(%(([0-9]*[.])?[0-9]+)?[dif])/g 21 | 22 | class ConsoleMessage extends React.Component { 23 | shouldComponentUpdate(nextProps) { 24 | return this.props.log.amount !== nextProps.log.amount 25 | } 26 | 27 | theme = (theme: Theme) => ({ 28 | ...theme, 29 | method: this.props.log.method, 30 | }) 31 | 32 | render() { 33 | const { log, components } = this.props 34 | const node = this.getNode() 35 | const MessageComponent = components?.Message || Message 36 | 37 | return ( 38 | 39 | 40 | 41 | {/* Align icon to adjacent text, and let the icon can be different size than the text */} 42 | 43 | {log.amount > 1 ? ( 44 | {log.amount} 45 | ) : ( 46 | 47 | )} 48 | 49 | 50 | {log.timestamp ? {log.timestamp} : } 51 | {node} 52 | 53 | 54 | ) 55 | } 56 | 57 | getNode() { 58 | const { log } = this.props 59 | 60 | // Error handling 61 | const error = this.typeCheck(log) 62 | if (error) return error 63 | 64 | // Chrome formatting 65 | if (log.data.length > 0 && typeof log.data[0] === 'string') { 66 | const matchLength = log.data[0].match(reSubstitutions)?.length 67 | if (matchLength) { 68 | const restData = log.data.slice(1 + matchLength) 69 | return ( 70 | <> 71 | 72 | {restData.length > 0 && ( 73 | 78 | )} 79 | 80 | ) 81 | } 82 | } 83 | 84 | // Error panel 85 | if ( 86 | log.data.every((message) => typeof message === 'string') && 87 | log.method === 'error' 88 | ) { 89 | return 90 | } 91 | 92 | // Normal inspector 93 | const quoted = typeof log.data[0] !== 'string' 94 | return ( 95 | 100 | ) 101 | } 102 | 103 | typeCheck(log: any) { 104 | if (!log) { 105 | return ( 106 | 114 | ) 115 | } else if (!(log.data instanceof Array)) { 116 | return ( 117 | 125 | ) 126 | } 127 | return false 128 | } 129 | } 130 | 131 | export default ConsoleMessage 132 | -------------------------------------------------------------------------------- /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 | 38 | ) 39 | 40 | const html = result.html() 41 | expect(html).toContain('test') 42 | expect(html).toContain('foo') 43 | expect(html).toContain('[2]') 44 | }) 45 | 46 | it('various data types', () => { 47 | const result = shallow( 48 | 66 | ) 67 | 68 | const html = result.html() 69 | expect(html).toContain('test') 70 | expect(html).toContain('foo:') 71 | expect(html).toContain( 72 | '"bar"' 73 | ) 74 | }) 75 | 76 | it('skips non-existent substitution', () => { 77 | const result = shallow( 78 | 87 | ) 88 | 89 | const html = result.html() 90 | expect(html).toContain('%u') 91 | expect(html).toContain('foo') 92 | }) 93 | 94 | it('displays object names', () => { 95 | const result = shallow( 96 | 105 | ) 106 | 107 | expect(result.html()).toContain( 108 | 'MyObject {}' 109 | ) 110 | }) 111 | 112 | it('linkify object', () => { 113 | const result = shallow( 114 | 123 | ) 124 | 125 | expect(result.html()).toContain( 126 | 'https://example.com' 127 | ) 128 | }) 129 | 130 | it('linkify object and pass options', () => { 131 | const result = shallow( 132 | (type === 'url' ? { rel: 'nofollow' } : {}), 142 | }} 143 | /> 144 | ) 145 | 146 | expect(result.html()).toContain( 147 | 'https://example.com' 148 | ) 149 | }) 150 | 151 | it('allows all types methods', () => { 152 | expect(() => 153 | shallow( 154 | 171 | ) 172 | ).not.toThrowError() 173 | }) 174 | 175 | it('displays limited arrays correctly', () => { 176 | const result = shallow( 177 | 191 | ) 192 | 193 | expect(result.html()).toContain('(99999)') 194 | expect(result.html()).toContain(']') 195 | }) 196 | 197 | it('displays nested limited arrays correctly', () => { 198 | const result = shallow( 199 | 216 | ) 217 | 218 | expect(result.html()).toContain('Array(99999)') 219 | }) 220 | -------------------------------------------------------------------------------- /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/devtools-parser/index.ts: -------------------------------------------------------------------------------- 1 | import 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/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/Component/elements.tsx: -------------------------------------------------------------------------------- 1 | import styled from './theme' 2 | 3 | /** 4 | * Return themed log-method style 5 | * @param style The style 6 | * @param type The method 7 | */ 8 | const Themed = ( 9 | style: string, 10 | method: string, 11 | styles: { [name: string]: string } 12 | ) => 13 | styles[`LOG_${method.toUpperCase()}_${style.toUpperCase()}`] || 14 | styles[`LOG_${style.toUpperCase()}`] 15 | 16 | /** 17 | * console-feed 18 | */ 19 | export const Root = styled('div')({ 20 | wordBreak: 'break-word', 21 | width: '100%', 22 | }) 23 | 24 | /** 25 | * console-message 26 | */ 27 | export const Message = styled('div')(({ theme: { styles, method } }) => ({ 28 | position: 'relative', 29 | display: 'flex', 30 | color: Themed('color', method, styles), 31 | backgroundColor: Themed('background', method, styles), 32 | borderTop: `1px solid ${Themed('border', method, styles)}`, 33 | borderBottom: `1px solid ${Themed('border', method, styles)}`, 34 | marginTop: -1, 35 | marginBottom: +/^warn|error$/.test(method), 36 | padding: styles.PADDING, 37 | boxSizing: 'border-box', 38 | '& *': { 39 | boxSizing: 'border-box', 40 | fontFamily: styles.BASE_FONT_FAMILY, 41 | whiteSpace: 'pre-wrap', 42 | fontSize: styles.BASE_FONT_SIZE, 43 | }, 44 | '& a': { 45 | color: styles.LOG_LINK_COLOR, 46 | }, 47 | })) 48 | 49 | /** 50 | * Icon container 51 | */ 52 | export const IconContainer = styled('div')(() => ({ 53 | paddingLeft: 10, 54 | })) 55 | 56 | /** 57 | * message-icon 58 | */ 59 | export const Icon = styled('div')(({ theme: { styles, method } }) => ({ 60 | width: styles.LOG_ICON_WIDTH, 61 | height: styles.LOG_ICON_HEIGHT, 62 | backgroundImage: Themed('icon', method, styles), 63 | backgroundRepeat: 'no-repeat', 64 | backgroundSize: styles.LOG_ICON_BACKGROUND_SIZE, 65 | backgroundPosition: 'center', 66 | })) 67 | 68 | /** 69 | * message-amount 70 | */ 71 | export const AmountIcon = styled('div')(({ theme: { styles, method } }) => ({ 72 | // make it a circle if the amount is one digit 73 | minWidth: `${16 / 12}em`, 74 | height: `${16 / 12}em`, 75 | margin: '1px 0', 76 | whiteSpace: 'nowrap', 77 | fontSize: `${10 / 12}em!important`, 78 | padding: '0px 3px', 79 | background: Themed('amount_background', method, styles), 80 | color: Themed('amount_color', method, styles), 81 | borderRadius: '9999px', 82 | display: 'flex', 83 | alignItems: 'center', 84 | justifyContent: 'center', 85 | })) 86 | 87 | /** 88 | * timestamp 89 | */ 90 | export const Timestamp = styled('div')(({ theme: { styles, method } }) => ({ 91 | marginLeft: 5, 92 | color: 'dimgray', 93 | })) 94 | 95 | /** 96 | * console-content 97 | */ 98 | export const Content = styled('div')(({ theme: { styles } }) => ({ 99 | clear: 'right', 100 | position: 'relative', 101 | marginLeft: 15, 102 | flex: 1, 103 | })) 104 | -------------------------------------------------------------------------------- /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 | const getTheme = (props: Props) => ({ 26 | variant: props.variant || 'light', 27 | styles: { 28 | ...Styles(props), 29 | ...props.styles, 30 | }, 31 | }) 32 | 33 | class Console extends React.PureComponent { 34 | state = { 35 | theme: getTheme(this.props), 36 | prevStyles: this.props.styles, 37 | prevVariant: this.props.variant, 38 | } 39 | 40 | static getDerivedStateFromProps(props, state) { 41 | if ( 42 | props.variant !== state.prevVariant || 43 | JSON.stringify(props.styles) !== JSON.stringify(props.prevStyles) 44 | ) { 45 | return { 46 | theme: getTheme(props), 47 | prevStyles: props.styles, 48 | prevVariant: props.variant, 49 | } 50 | } 51 | return null 52 | } 53 | 54 | render() { 55 | let { 56 | filter = [], 57 | logs = [], 58 | searchKeywords, 59 | logFilter, 60 | logGrouping = true, 61 | } = this.props 62 | 63 | if (searchKeywords) { 64 | const regex = new RegExp(searchKeywords) 65 | 66 | const filterFun = logFilter 67 | ? logFilter 68 | : (log) => { 69 | try { 70 | return regex.test(customStringify(log)) 71 | } catch (e) { 72 | return true 73 | } 74 | } 75 | 76 | // @ts-ignore 77 | logs = logs.filter(filterFun) 78 | } 79 | 80 | if (logGrouping) { 81 | // @ts-ignore 82 | logs = logs.reduce((acc, log) => { 83 | const prevLog = acc[acc.length - 1] 84 | 85 | if ( 86 | prevLog && 87 | prevLog.amount && 88 | prevLog.method === log.method && 89 | prevLog.data.length === log.data.length && 90 | prevLog.data.every((value, i) => log.data[i] === value) 91 | ) { 92 | prevLog.amount += 1 93 | 94 | return acc 95 | } 96 | 97 | acc.push({ ...log, amount: 1 }) 98 | 99 | return acc 100 | }, []) 101 | } 102 | 103 | return ( 104 | 105 | 106 | {logs.map((log, i) => { 107 | // If the filter is defined and doesn't include the method 108 | const filtered = 109 | filter.length !== 0 && 110 | log.method && 111 | filter.indexOf(log.method) === -1 112 | 113 | return filtered ? null : ( 114 | 120 | ) 121 | })} 122 | 123 | 124 | ) 125 | } 126 | } 127 | 128 | export default Console 129 | -------------------------------------------------------------------------------- /src/Component/message-parsers/Error.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Linkify from 'linkifyjs/react' 3 | 4 | function splitMessage(message: string): string { 5 | const breakIndex = message.indexOf('\n') 6 | // consider that there can be line without a break 7 | if (breakIndex === -1) { 8 | return message 9 | } 10 | return message.substr(0, breakIndex) 11 | } 12 | 13 | function ErrorPanel({ error }: { error: string }) { 14 | /* This checks for error logTypes and shortens the message in the console by wrapping 15 | it a
tag and putting the first line in a tag and the other lines 16 | follow after that. This creates a nice collapsible error message */ 17 | let otherErrorLines 18 | const firstLine = splitMessage(error) 19 | const msgArray = error.split('\n') 20 | if (msgArray.length > 1) { 21 | otherErrorLines = msgArray.slice(1) 22 | } 23 | 24 | if (!otherErrorLines) { 25 | return {error} 26 | } 27 | 28 | return ( 29 |
30 | 31 | {firstLine} 32 | 33 | {otherErrorLines.join('\n\r')} 34 |
35 | ) 36 | } 37 | 38 | export default ErrorPanel 39 | -------------------------------------------------------------------------------- /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/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 Linkify from 'linkifyjs/react' 7 | import type { Options } from 'linkifyjs' 8 | import { Message } from '../../definitions/Component' 9 | import Inspector from '../react-inspector' 10 | 11 | interface Props { 12 | log: Message 13 | quoted: boolean 14 | theme?: Theme 15 | linkifyOptions?: Options 16 | } 17 | 18 | class ObjectTree extends React.PureComponent { 19 | render() { 20 | const { theme, quoted, log } = this.props 21 | 22 | return log.data.map((message: any, i: number) => { 23 | if (typeof message === 'string') { 24 | const string = 25 | !quoted && message.length ? ( 26 | `${message} ` 27 | ) : ( 28 | 29 | " 30 | 35 | {message} 36 | 37 | " 38 | 39 | ) 40 | 41 | return ( 42 | 43 | {string} 44 | 45 | ) 46 | } 47 | 48 | return 49 | }) 50 | } 51 | } 52 | 53 | export default withTheme(ObjectTree) 54 | -------------------------------------------------------------------------------- /src/Component/react-inspector/elements.tsx: -------------------------------------------------------------------------------- 1 | import styled from '../theme' 2 | 3 | /** 4 | * Object root 5 | */ 6 | export const Root = styled('div')({ 7 | display: 'inline-block', 8 | wordBreak: 'break-all', 9 | '&::after': { 10 | content: `' '`, 11 | display: 'inline-block', 12 | }, 13 | '& > li, & > ol, & > details': { 14 | backgroundColor: 'transparent !important', 15 | display: 'inline-block', 16 | }, 17 | '& ol:empty': { 18 | paddingLeft: '0 !important', 19 | }, 20 | }) 21 | 22 | /** 23 | * Table 24 | */ 25 | export const Table = styled('span')({ 26 | '& > li': { 27 | display: 'inline-block', 28 | marginTop: 5, 29 | }, 30 | // override react-inspector/TableInspectorHeaderContainer.base 31 | '& div[style*="height: 17px"]': { 32 | height: `${17 / 12}em!important`, 33 | }, 34 | // override react-inspector/TableInspectorDataContainer.td 35 | '& td[style*="height: 16px"]': { 36 | height: `${16 / 12}em!important`, 37 | lineHeight: `1!important`, 38 | verticalAlign: 'middle!important', 39 | }, 40 | '& table[style*="background-size: 128px 32px"]': { 41 | // = td's fontSize * 2 42 | backgroundSize: `128px ${(16 / 12) * 2}em!important`, 43 | }, 44 | }) 45 | 46 | /** 47 | * HTML 48 | */ 49 | export const HTML = styled('span')({ 50 | display: 'inline-block', 51 | '& div:hover': { 52 | backgroundColor: 'rgba(255, 220, 158, .05) !important', 53 | borderRadius: '2px', 54 | }, 55 | }) 56 | 57 | /** 58 | * Object constructor 59 | */ 60 | export const Constructor = styled('span')({ 61 | '& > span > span:nth-child(1)': { 62 | opacity: 0.6, 63 | }, 64 | }) 65 | -------------------------------------------------------------------------------- /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 | ObjectValue, 9 | ObjectPreview, 10 | } from 'react-inspector' 11 | 12 | import { Context } from '../../definitions/Component' 13 | import ErrorPanel from '../message-parsers/Error' 14 | import { Constructor, HTML, Root, Table } from './elements' 15 | 16 | interface Props { 17 | theme?: Context 18 | data: any 19 | } 20 | 21 | const REMAINING_KEY = '__console_feed_remaining__' 22 | 23 | // copied from react-inspector 24 | function intersperse(arr, sep) { 25 | if (arr.length === 0) { 26 | return [] 27 | } 28 | 29 | return arr.slice(1).reduce((xs, x) => xs.concat([sep, x]), [arr[0]]) 30 | } 31 | 32 | const getArrayLength = (array: Array) => { 33 | if (!array || array.length < 1) { 34 | return 0 35 | } 36 | 37 | const remainingKeyCount = array[array.length - 1] 38 | .toString() 39 | .split(REMAINING_KEY) 40 | 41 | if (remainingKeyCount[1] === undefined) { 42 | return array.length 43 | } else { 44 | const remaining = parseInt( 45 | array[array.length - 1].toString().split(REMAINING_KEY)[1] 46 | ) 47 | 48 | return array.length - 1 + remaining 49 | } 50 | } 51 | 52 | const CustomObjectRootLabel = ({ name, data }) => { 53 | let rootData = data 54 | if (typeof data === 'object' && !Array.isArray(data) && data !== null) { 55 | const object = {} 56 | for (const propertyName in data) { 57 | if (data.hasOwnProperty(propertyName)) { 58 | let propertyValue = data[propertyName] 59 | if (Array.isArray(propertyValue)) { 60 | const arrayLength = getArrayLength(propertyValue) 61 | object[propertyName] = new Array(arrayLength) 62 | } else { 63 | object[propertyName] = propertyValue 64 | } 65 | } 66 | } 67 | rootData = Object.assign(Object.create(Object.getPrototypeOf(data)), object) 68 | } 69 | if (typeof name === 'string') { 70 | return ( 71 | 72 | 73 | : 74 | 75 | 76 | ) 77 | } else { 78 | return 79 | } 80 | } 81 | 82 | const CustomObjectLabel = ({ name, data, isNonenumerable = false }) => 83 | name === REMAINING_KEY ? ( 84 | data > 0 ? ( 85 | {data} more... 86 | ) : null 87 | ) : ( 88 | 89 | {typeof name === 'string' ? ( 90 | 91 | ) : ( 92 | 93 | )} 94 | : 95 | 96 | 97 | 98 | ) 99 | 100 | class CustomInspector extends React.PureComponent { 101 | render() { 102 | const { data, theme } = this.props 103 | const { styles, method } = theme 104 | 105 | const dom = data instanceof HTMLElement 106 | const table = method === 'table' 107 | 108 | return ( 109 | 110 | {table ? ( 111 | 112 | 113 | 118 |
119 | ) : dom ? ( 120 | 121 | 122 | 123 | ) : ( 124 | 129 | )} 130 |
131 | ) 132 | } 133 | 134 | getCustomNode(data: any) { 135 | const { styles } = this.props.theme 136 | const constructor = data?.constructor?.name 137 | 138 | if (constructor === 'Function') 139 | return ( 140 | 141 | 142 | {` {`} 143 | {data.body} 144 | {`}`} 145 | 146 | ) 147 | 148 | if (data instanceof Error && typeof data.stack === 'string') { 149 | return 150 | } 151 | 152 | if (constructor === 'Promise') 153 | return ( 154 | 155 | Promise {`{`} 156 | {``} 157 | {`}`} 158 | 159 | ) 160 | 161 | if (data instanceof HTMLElement) 162 | return ( 163 | 164 | 165 | 166 | ) 167 | 168 | if (Array.isArray(data)) { 169 | const arrayLength = getArrayLength(data) 170 | const maxProperties = styles.OBJECT_PREVIEW_ARRAY_MAX_PROPERTIES 171 | 172 | if ( 173 | typeof data[data.length - 1] === 'string' && 174 | data[data.length - 1].includes(REMAINING_KEY) 175 | ) { 176 | data = data.slice(0, -1) 177 | } 178 | 179 | const previewArray = data 180 | .slice(0, maxProperties) 181 | .map((element, index) => { 182 | if (Array.isArray(element)) { 183 | return ( 184 | 188 | ) 189 | } else { 190 | return 191 | } 192 | }) 193 | if (arrayLength > maxProperties) { 194 | previewArray.push() 195 | } 196 | return ( 197 | 198 | 199 | {arrayLength === 0 ? `` : `(${arrayLength})\xa0`} 200 | 201 | 202 | [{intersperse(previewArray, ', ')} 203 | {}] 204 | 205 | 206 | ) 207 | } 208 | 209 | return null 210 | } 211 | 212 | nodeRenderer(props: any) { 213 | let { depth, name, data, isNonenumerable } = props 214 | 215 | // Root 216 | if (depth === 0) { 217 | const customNode = this.getCustomNode(data) 218 | return customNode || 219 | } 220 | 221 | if (typeof data === 'string' && data.includes(REMAINING_KEY)) { 222 | name = REMAINING_KEY 223 | data = data.split(REMAINING_KEY)[1] 224 | } 225 | 226 | if (name === 'constructor') 227 | return ( 228 | 229 | 234 | 235 | ) 236 | 237 | const customNode = this.getCustomNode(data) 238 | 239 | return customNode ? ( 240 | 241 | 242 | : 243 | {customNode} 244 | 245 | ) : ( 246 | 251 | ) 252 | } 253 | } 254 | 255 | export default withTheme(CustomInspector) 256 | -------------------------------------------------------------------------------- /src/Component/react-inspector/util.ts: -------------------------------------------------------------------------------- 1 | export const isMinusZero = value => 1 / value === -Infinity 2 | -------------------------------------------------------------------------------- /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 | const isLight = (props.variant || 'light') === 'light' 7 | const chrome = isLight ? chromeLight : chromeDark 8 | 9 | return { 10 | ...chrome, 11 | /** 12 | * General 13 | */ 14 | PADDING: '3px 22px 2px 0', 15 | 16 | /** 17 | * Default log styles 18 | */ 19 | LOG_COLOR: chrome.BASE_COLOR, 20 | LOG_BACKGROUND: 'transparent', 21 | LOG_BORDER: isLight ? 'rgb(236,236,236)' : 'rgb(44,44,44)', 22 | LOG_ICON_WIDTH: `${10 / 12}em`, 23 | LOG_ICON_HEIGHT: `${10 / 12}em`, 24 | LOG_ICON_BACKGROUND_SIZE: 'contain', 25 | LOG_ICON: 'none', 26 | LOG_AMOUNT_BACKGROUND: '#42597f', 27 | LOG_AMOUNT_COLOR: '#8d8f91', 28 | LOG_LINK_COLOR: isLight ? 'rgb(66, 66, 66)' : 'rgb(177, 177, 177)', 29 | 30 | /** 31 | * Log types 32 | */ 33 | LOG_WARN_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACkSURBVChTbY7BCoJQFERn0Q/3BX1JuxQjsSCXiV8gtCgxhCIrKIRIqKDVzXl5w5cNHBjm6eGinXiAXu5inY2xYm/mbpIh+vcFhLA3sx0athNUhymEsP+10lAEEA17x8o/9wFuNGnYuVlWve0SQl7P0sBu3aq2R1Q/1JzSkYGd29eqNv2wjdnUuvNRciC/N+qe+7gidbA8zyHkOINsvA/sumcOkjcabcBmw2+mMgAAAABJRU5ErkJggg==)`, 34 | LOG_WARN_BACKGROUND: isLight ? 'rgb(255,250,220)' : '#332b00', 35 | LOG_WARN_COLOR: isLight ? 'rgb(73,45,2)' : '#ffdc9e', 36 | LOG_WARN_BORDER: isLight ? 'rgb(255,244,181)' : '#650', 37 | LOG_WARN_AMOUNT_BACKGROUND: '#ffbb17', 38 | LOG_WARN_AMOUNT_COLOR: '#8d8f91', 39 | 40 | LOG_ERROR_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADESURBVChTY4CB7ZI8tmfU5E6e01b+DMIgNkgMKg0BR9Vkux6YWPx/bemIgkFiIDmwogOaqrYPzazAEm8DwuGKYGyQHEgNw0VT05Mwib9v3v7/kJEHxiA2TDFIDcNNU4vPMFPACj58/P/v40cwGyYOUsNwy8IZRSFIEUgxskKQGoZrzp4ErQapYbgYHG371M4dLACTQGaD5EBqwD6/FpzQ9dTBE64IhkFiIDmwIhi4mlJqey8o4eR9r8jPIAxig8QgsgwMAFZz1YtGPXgjAAAAAElFTkSuQmCC)`, 41 | LOG_ERROR_BACKGROUND: isLight ? 'rgb(255,235,235)' : '#290000', 42 | LOG_ERROR_BORDER: isLight ? 'rgb(253,204,205)' : '#5b0000', 43 | LOG_ERROR_COLOR: isLight ? 'rgb(252,0,5)' : '#ff8080', 44 | LOG_ERROR_AMOUNT_BACKGROUND: '#dc2727', 45 | LOG_ERROR_AMOUNT_COLOR: '#8d8f91', 46 | 47 | 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")`, 48 | LOG_DEBUG_BACKGROUND: '', 49 | LOG_DEBUG_BORDER: '', 50 | LOG_DEBUG_COLOR: '#4D88FF', 51 | 52 | LOG_COMMAND_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABaSURBVChTY6AtmDx5cvnUqVP1oFzsoL+/XwCo8DEQv584caIVVBg7mDBhghxQ4Y2+vr6vU6ZM8YAKYwdA00SB+CxQ8S+g4jCoMCYgSiFRVpPkGaAiHMHDwAAA5Ko+F4/l6+MAAAAASUVORK5CYII=)`, 53 | LOG_RESULT_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABpSURBVChTY6A92LNnj96uXbvKoVzsYMeOHVbbt29/D1T4eP/+/QJQYVSwe/duD6CCr0B8A8iWgwqjAqBk2NatW38B6bPbtm0TBYkBFbsA+c9ANFgRCBCtEASAAoSthgGiPAMD2IOHgQEA521bM7uG52wAAAAASUVORK5CYII=)`, 54 | LOG_INFO_ICON: `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADISURBVChTY4ABp/AztmZBZ07qe538rO114rOa8+GTskYHbKHSEOARd6nLIOTsf61gIA46U6kVePYQiK3uc/K/hPG+LrCi8IyrtkZh5yCKgk/80w46ba0RdGYGhH/2v6rXyf88qtttGVwSLp2ECQLxeiAu1wo6uwpJ7L+o2f6TDA6xZz8jCyqFnuHXCj4djywmZXHoM/EK0azGqhBsNYpngL6VCTnGqRF4xgKo+D5IDO4ZEEAKnjcQBafvqwWf/YoSPDCAP8AZGAC7mLM81zgOTQAAAABJRU5ErkJggg==)`, 55 | 56 | /** 57 | * Fonts 58 | */ 59 | BASE_FONT_FAMILY: 'Consolas, Lucida Console, Courier New, monospace', 60 | BASE_FONT_SIZE: '12px', 61 | 62 | /** 63 | * Other 64 | */ 65 | ARROW_FONT_SIZE: `${10 / 12}em`, 66 | OBJECT_VALUE_STRING_COLOR: 'rgb(233,63,59)', 67 | } as Styles 68 | } 69 | 70 | export default styles 71 | -------------------------------------------------------------------------------- /src/Component/theme/index.ts: -------------------------------------------------------------------------------- 1 | import styled, { CreateStyled } from '@emotion/styled' 2 | import { Context } from '../../definitions/Component' 3 | 4 | export default styled as CreateStyled 5 | -------------------------------------------------------------------------------- /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 `bigint`', async () => { 25 | const result = await Log('warn', BigInt(1)) 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 a HTMLElement', async () => { 34 | const result = await Log('warn', document.documentElement) 35 | expect(result).toBeTruthy() 36 | 37 | const decoded = Decode(result) 38 | expect(decoded.method).toEqual('warn') 39 | expect(decoded.data).toMatchSnapshot() 40 | }) 41 | 42 | it('correctly encodes Functions', async () => { 43 | // prettier-ignore 44 | const result = await Log('error', function myFunc() { /* body */ }) 45 | 46 | const decoded = Decode(result) 47 | expect(decoded.method).toEqual('error') 48 | expect(decoded.data).toMatchSnapshot() 49 | }) 50 | 51 | it('correctly encodes nested values', async () => { 52 | const input = { 53 | function: function myFunc() {}, 54 | document: document.documentElement, 55 | nested: [[[new Promise(() => {})]]], 56 | recursive: null, 57 | } 58 | input.recursive = input 59 | 60 | const result = await Log('debug', input) 61 | 62 | const decoded = Decode(result) 63 | expect(decoded.method).toEqual('debug') 64 | expect(decoded.data).toMatchSnapshot() 65 | }) 66 | 67 | it('disables encoding with a flag', async () => { 68 | Hook( 69 | console, 70 | (log) => { 71 | console.logs.push(log) 72 | }, 73 | false 74 | ) 75 | const input = { 76 | function: function myFunc() {}, 77 | document: document.documentElement, 78 | nested: [[[new Promise(() => {})]]], 79 | recursive: null, 80 | } 81 | input.recursive = input 82 | 83 | const result: any = await Log('debug', input) 84 | 85 | expect(result.data).toMatchSnapshot() 86 | }) 87 | 88 | it('correctly limits a long array', async () => { 89 | Hook( 90 | console, 91 | (log) => { 92 | console.logs.push(log) 93 | }, 94 | true, 95 | 100 96 | ) 97 | const result = await Log('log', Array.from(Array(99999).keys())) 98 | expect(result[0].data[0].length).toEqual(101) 99 | expect(result[0].data[0].pop()).toEqual('__console_feed_remaining__99899') 100 | }) 101 | 102 | it('correctly limits a long object', async () => { 103 | Hook( 104 | console, 105 | (log) => { 106 | console.logs.push(log) 107 | }, 108 | true, 109 | 100 110 | ) 111 | const result = await Log('log', { ...Array.from(Array(99999).keys()) }) 112 | expect(Object.keys(result[0].data[0]).length).toEqual(101) 113 | expect(result[0].data[0]['__console_feed_remaining__']).toEqual(99899) 114 | }) 115 | -------------------------------------------------------------------------------- /src/Hook/__tests__/Log.tsx: -------------------------------------------------------------------------------- 1 | import console from './console' 2 | import { Message } from '../../definitions/Console' 3 | 4 | function Log(type: string, ...data: any[]): Promise { 5 | return new Promise((resolve, reject) => { 6 | const length = console.logs.length 7 | console[type](...data) 8 | 9 | setTimeout(() => { 10 | if (console.logs.length !== length) { 11 | resolve(console.logs[console.logs.length - 1]) 12 | } 13 | reject() 14 | }) 15 | }) 16 | } 17 | 18 | export default Log 19 | -------------------------------------------------------------------------------- /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 \`bigint\` 1`] = ` 10 | Array [ 11 | BigInt {}, 12 | ] 13 | `; 14 | 15 | exports[`correctly encodes a HTMLElement 1`] = ` 16 | Array [ 17 | 18 | 19 | 20 | , 21 | ] 22 | `; 23 | 24 | exports[`correctly encodes nested values 1`] = ` 25 | Array [ 26 | Object { 27 | "document": 28 | 29 | 30 | , 31 | "function": [Function], 32 | "nested": Array [ 33 | Array [ 34 | Array [ 35 | Object { 36 | "constructor": Object { 37 | "name": "Promise", 38 | }, 39 | }, 40 | "__console_feed_remaining__0", 41 | ], 42 | "__console_feed_remaining__0", 43 | ], 44 | "__console_feed_remaining__0", 45 | ], 46 | "recursive": [Circular], 47 | }, 48 | ] 49 | `; 50 | 51 | exports[`decodes messages 1`] = ` 52 | Array [ 53 | "test", 54 | ] 55 | `; 56 | 57 | exports[`disables encoding with a flag 1`] = ` 58 | Array [ 59 | Object { 60 | "document": 61 | 62 | 63 | , 64 | "function": [Function], 65 | "nested": Array [ 66 | Array [ 67 | Array [ 68 | Promise {}, 69 | ], 70 | ], 71 | ], 72 | "recursive": [Circular], 73 | }, 74 | ] 75 | `; 76 | -------------------------------------------------------------------------------- /src/Hook/__tests__/console.ts: -------------------------------------------------------------------------------- 1 | import { HookedConsole, Message } from '../../definitions/Console' 2 | 3 | interface Console extends HookedConsole { 4 | logs: Message[] 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/construct.ts: -------------------------------------------------------------------------------- 1 | const construct = (name: string): any => ({ 2 | constructor: { 3 | name 4 | } 5 | }) 6 | 7 | export default construct 8 | -------------------------------------------------------------------------------- /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 | limit?: number 25 | ) { 26 | const TargetConsole = console as HookedConsole 27 | const Storage: Storage = { 28 | pointers: {}, 29 | src: { 30 | npm: 'https://npmjs.com/package/console-feed', 31 | github: 'https://github.com/samdenty/console-feed', 32 | }, 33 | } 34 | 35 | // Override console methods 36 | for (let method of Methods) { 37 | const NativeMethod = TargetConsole[method] 38 | 39 | // Override 40 | TargetConsole[method] = function () { 41 | // Pass back to native method 42 | NativeMethod.apply(this, arguments) 43 | 44 | // Parse arguments and send to transport 45 | const args = [].slice.call(arguments) 46 | 47 | // setTimeout to prevent lag 48 | setTimeout(() => { 49 | const parsed = Parse(method as ConsoleMethods, args) 50 | if (parsed) { 51 | let encoded: Message = parsed as Message 52 | if (encode) { 53 | encoded = Encode(parsed, limit) as Message 54 | } 55 | callback(encoded, parsed) 56 | } 57 | }) 58 | } 59 | 60 | // Store native methods 61 | Storage.pointers[method] = NativeMethod 62 | } 63 | 64 | TargetConsole.feed = Storage 65 | 66 | return TargetConsole 67 | } 68 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /src/Hook/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../../definitions/Store' 2 | 3 | export const initialState = { 4 | timings: {}, 5 | count: {}, 6 | } 7 | 8 | const now = () => { 9 | return typeof performance !== 'undefined' && performance.now 10 | ? performance.now() 11 | : Date.now() 12 | } 13 | 14 | export default (state = initialState, action: Action) => { 15 | switch (action.type) { 16 | case 'COUNT': { 17 | const times = state.count[action.name] || 0 18 | 19 | return { 20 | ...state, 21 | count: { 22 | ...state.count, 23 | [action.name]: times + 1, 24 | }, 25 | } 26 | } 27 | 28 | case 'TIME_START': { 29 | return { 30 | ...state, 31 | timings: { 32 | ...state.timings, 33 | [action.name]: { 34 | start: now(), 35 | }, 36 | }, 37 | } 38 | } 39 | 40 | case 'TIME_END': { 41 | const timing = state.timings[action.name] 42 | 43 | const end = now() 44 | const { start } = timing 45 | 46 | const time = end - start 47 | 48 | return { 49 | ...state, 50 | timings: { 51 | ...state.timings, 52 | [action.name]: { 53 | ...timing, 54 | end, 55 | time, 56 | }, 57 | }, 58 | } 59 | } 60 | 61 | default: { 62 | return state 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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/Transform/BigInt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Serialize a `bigint` to a string 3 | */ 4 | export default { 5 | type: 'BigInt', 6 | shouldTransform(_type: any, obj: any) { 7 | return typeof obj === 'bigint' 8 | }, 9 | toSerializable(value: bigint) { 10 | return `${value}n` 11 | }, 12 | fromSerializable(data: string) { 13 | return BigInt(data.slice(0, -1)) 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /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 | lookup: Function, 13 | shouldTransform(type: any, obj: any) { 14 | return typeof obj === 'function' 15 | }, 16 | toSerializable(func: Function): Storage { 17 | let body = '' 18 | try { 19 | body = func 20 | .toString() 21 | .substring(body.indexOf('{') + 1, body.lastIndexOf('}')) 22 | } catch (e) {} 23 | 24 | return { 25 | name: func.name, 26 | body, 27 | proto: Object.getPrototypeOf(func).constructor.name, 28 | } 29 | }, 30 | fromSerializable(data: Storage) { 31 | try { 32 | const tempFunc = function () {} 33 | 34 | if (typeof data.name === 'string') { 35 | Object.defineProperty(tempFunc, 'name', { 36 | value: data.name, 37 | writable: false, 38 | }) 39 | } 40 | 41 | if (typeof data.body === 'string') { 42 | Object.defineProperty(tempFunc, 'body', { 43 | value: data.body, 44 | writable: false, 45 | }) 46 | } 47 | 48 | if (typeof data.proto === 'string') { 49 | // @ts-ignore 50 | tempFunc.constructor = { 51 | name: data.proto, 52 | } 53 | } 54 | 55 | return tempFunc 56 | } catch (e) { 57 | return data 58 | } 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /src/Transform/HTML.ts: -------------------------------------------------------------------------------- 1 | // Sandbox HTML elements 2 | let sandbox: Document 3 | function getSandbox() { 4 | return (sandbox ||= document.implementation.createHTMLDocument('sandbox')) 5 | } 6 | 7 | interface Storage { 8 | tagName: string 9 | attributes: { 10 | [attribute: string]: string 11 | } 12 | innerHTML: string 13 | } 14 | 15 | function objectifyAttributes(element: any) { 16 | const data = {} 17 | for (let attribute of element.attributes) { 18 | data[attribute.name] = attribute.value 19 | } 20 | return data 21 | } 22 | 23 | /** 24 | * Serialize a HTML element into JSON 25 | */ 26 | export default { 27 | type: 'HTMLElement', 28 | shouldTransform(type: any, obj: any) { 29 | return ( 30 | obj && 31 | obj.children && 32 | typeof obj.innerHTML === 'string' && 33 | typeof obj.tagName === 'string' 34 | ) 35 | }, 36 | toSerializable(element: HTMLElement) { 37 | return { 38 | tagName: element.tagName.toLowerCase(), 39 | attributes: objectifyAttributes(element), 40 | innerHTML: element.innerHTML, 41 | } as Storage 42 | }, 43 | fromSerializable(data: Storage) { 44 | try { 45 | const element = getSandbox().createElement(data.tagName) 46 | element.innerHTML = data.innerHTML 47 | for (let attribute of Object.keys(data.attributes)) { 48 | try { 49 | element.setAttribute(attribute, data.attributes[attribute]) 50 | } catch (e) {} 51 | } 52 | return element 53 | } catch (e) { 54 | return data 55 | } 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /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 | lookup: Map, 13 | shouldTransform(type: any, obj: any) { 14 | return obj && obj.constructor && obj.constructor.name === 'Map' 15 | }, 16 | toSerializable(map: any): Storage { 17 | let body = {} 18 | 19 | map.forEach(function (value, key) { 20 | const k = typeof key == 'object' ? JSON.stringify(key) : key 21 | body[k] = value 22 | }) 23 | 24 | return { 25 | name: 'Map', 26 | body, 27 | proto: Object.getPrototypeOf(map).constructor.name, 28 | } 29 | }, 30 | fromSerializable(data: Storage) { 31 | const { body } = data 32 | let obj = { ...body } 33 | 34 | if (typeof data.proto === 'string') { 35 | // @ts-ignore 36 | obj.constructor = { 37 | name: data.proto, 38 | } 39 | } 40 | 41 | return obj 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /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 | lookup: Number, 14 | shouldTransform(type: any, value: any) { 15 | return ( 16 | type === 'number' && 17 | (value === Infinity || value === -Infinity || isMinusZero(value)) 18 | ) 19 | }, 20 | toSerializable(value): Arithmetic { 21 | return value === Infinity 22 | ? Arithmetic.infinity 23 | : value === -Infinity 24 | ? Arithmetic.minusInfinity 25 | : Arithmetic.minusZero 26 | }, 27 | fromSerializable(data: Arithmetic) { 28 | if (data === Arithmetic.infinity) return Infinity 29 | if (data === Arithmetic.minusInfinity) return -Infinity 30 | if (data === Arithmetic.minusZero) return -0 31 | 32 | return data 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /src/Transform/index.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../definitions/Console' 2 | import Arithmetic from './arithmetic' 3 | import BigInt from './BigInt' 4 | import Function from './Function' 5 | import HTML from './HTML' 6 | import Map from './Map' 7 | 8 | import Replicator from './replicator' 9 | 10 | const transforms = [HTML, Function, Arithmetic, Map, BigInt] 11 | 12 | const replicator = new Replicator() 13 | replicator.addTransforms(transforms) 14 | 15 | export function Encode(data: any, limit?: number): T { 16 | return JSON.parse(replicator.encode(data, limit)) 17 | } 18 | 19 | export function Decode(data: any): Message { 20 | const decoded = replicator.decode(JSON.stringify(data)) 21 | // remove __console_feed_remaining__ 22 | decoded.data.pop() 23 | return decoded 24 | } 25 | -------------------------------------------------------------------------------- /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 REMAINING_KEY = '__console_feed_remaining__' 7 | 8 | const GLOBAL = (function getGlobal() { 9 | // NOTE: see http://www.ecma-international.org/ecma-262/6.0/index.html#sec-performeval step 10 10 | const savedEval = eval 11 | 12 | return savedEval('this') 13 | })() 14 | 15 | const ARRAY_BUFFER_SUPPORTED = typeof ArrayBuffer === 'function' 16 | const MAP_SUPPORTED = typeof Map === 'function' 17 | const SET_SUPPORTED = typeof Set === 'function' 18 | 19 | const TYPED_ARRAY_CTORS = [ 20 | 'Int8Array', 21 | 'Uint8Array', 22 | 'Uint8ClampedArray', 23 | 'Int16Array', 24 | 'Uint16Array', 25 | 'Int32Array', 26 | 'Uint32Array', 27 | 'Float32Array', 28 | 'Float64Array', 29 | ] 30 | 31 | // Saved proto functions 32 | const arrSlice = Array.prototype.slice 33 | 34 | // Default serializer 35 | const JSONSerializer = { 36 | serialize(val: any) { 37 | return JSON.stringify(val) 38 | }, 39 | 40 | deserialize(val: any) { 41 | return JSON.parse(val) 42 | }, 43 | } 44 | 45 | // EncodingTransformer 46 | class EncodingTransformer { 47 | references: any 48 | transforms: any 49 | transformsMap: any 50 | circularCandidates: any 51 | circularCandidatesDescrs: any 52 | circularRefCount: any 53 | limit: number 54 | 55 | constructor(val: any, transforms: any, limit?: number) { 56 | this.references = val 57 | this.transforms = transforms 58 | this.transformsMap = this._makeTransformsMap() 59 | this.circularCandidates = [] 60 | this.circularCandidatesDescrs = [] 61 | this.circularRefCount = 0 62 | this.limit = limit ?? Infinity 63 | } 64 | 65 | static _createRefMark(idx: any) { 66 | const obj = Object.create(null) 67 | 68 | obj[CIRCULAR_REF_KEY] = idx 69 | 70 | return obj 71 | } 72 | 73 | _createCircularCandidate(val: any, parent: any, key: any) { 74 | this.circularCandidates.push(val) 75 | this.circularCandidatesDescrs.push({ parent, key, refIdx: -1 }) 76 | } 77 | 78 | _applyTransform(val: any, parent: any, key: any, transform: any) { 79 | const result = Object.create(null) 80 | const serializableVal = transform.toSerializable(val) 81 | 82 | if (typeof serializableVal === 'object') 83 | this._createCircularCandidate(val, parent, key) 84 | 85 | result[TRANSFORMED_TYPE_KEY] = transform.type 86 | result.data = this._handleValue(() => serializableVal, parent, key) 87 | 88 | return result 89 | } 90 | 91 | _handleArray(arr: any): any { 92 | const result = [] as any 93 | const arrayLimit = Math.min(arr.length, this.limit) 94 | const remaining = arr.length - arrayLimit 95 | 96 | for (let i = 0; i < arrayLimit; i++) 97 | result[i] = this._handleValue(() => arr[i], result, i) 98 | 99 | result[arrayLimit] = REMAINING_KEY + remaining 100 | 101 | return result 102 | } 103 | 104 | _handlePlainObject(obj: any) { 105 | const result = Object.create(null) 106 | let counter = 0 107 | let total = 0 108 | for (const key in obj) { 109 | if (Reflect.has(obj, key)) { 110 | if (counter >= this.limit) { 111 | total++ 112 | continue 113 | } 114 | const resultKey = KEY_REQUIRE_ESCAPING_RE.test(key) ? `#${key}` : key 115 | 116 | result[resultKey] = this._handleValue(() => obj[key], result, resultKey) 117 | counter++ 118 | total++ 119 | } 120 | } 121 | 122 | const remaining = total - counter 123 | 124 | const name = obj?.__proto__?.constructor?.name 125 | if (name && name !== 'Object') { 126 | result.constructor = { name } 127 | } 128 | 129 | if (remaining) { 130 | result[REMAINING_KEY] = remaining 131 | } 132 | 133 | return result 134 | } 135 | 136 | _handleObject(obj: any, parent: any, key: any) { 137 | this._createCircularCandidate(obj, parent, key) 138 | 139 | return Array.isArray(obj) 140 | ? this._handleArray(obj) 141 | : this._handlePlainObject(obj) 142 | } 143 | 144 | _ensureCircularReference(obj: any) { 145 | const circularCandidateIdx = this.circularCandidates.indexOf(obj) 146 | 147 | if (circularCandidateIdx > -1) { 148 | const descr = this.circularCandidatesDescrs[circularCandidateIdx] 149 | 150 | if (descr.refIdx === -1) 151 | descr.refIdx = descr.parent ? ++this.circularRefCount : 0 152 | 153 | return EncodingTransformer._createRefMark(descr.refIdx) 154 | } 155 | 156 | return null 157 | } 158 | 159 | _handleValue(getVal: () => any, parent: any, key: any) { 160 | try { 161 | const val = getVal() 162 | const type = typeof val 163 | const isObject = type === 'object' && val !== null 164 | 165 | if (isObject) { 166 | const refMark = this._ensureCircularReference(val) 167 | 168 | if (refMark) return refMark 169 | } 170 | 171 | const transform = this._findTransform(type, val) 172 | 173 | if (transform) { 174 | return this._applyTransform(val, parent, key, transform) 175 | } 176 | 177 | if (isObject) return this._handleObject(val, parent, key) 178 | 179 | return val 180 | } catch (e) { 181 | try { 182 | return this._handleValue( 183 | () => (e instanceof Error ? e : new Error(e)), 184 | parent, 185 | key 186 | ) 187 | } catch { 188 | return null 189 | } 190 | } 191 | } 192 | 193 | _makeTransformsMap() { 194 | if (!MAP_SUPPORTED) { 195 | return 196 | } 197 | 198 | const map = new Map() 199 | this.transforms.forEach((transform) => { 200 | if (transform.lookup) { 201 | map.set(transform.lookup, transform) 202 | } 203 | }) 204 | return map 205 | } 206 | 207 | _findTransform(type: string, val: any) { 208 | if (MAP_SUPPORTED) { 209 | if (val && val.constructor) { 210 | const transform = this.transformsMap.get(val.constructor) 211 | 212 | if (transform?.shouldTransform(type, val)) return transform 213 | } 214 | } 215 | 216 | for (const transform of this.transforms) { 217 | if (transform.shouldTransform(type, val)) return transform 218 | } 219 | } 220 | 221 | transform() { 222 | const references = [this._handleValue(() => this.references, null, null)] 223 | 224 | for (const descr of this.circularCandidatesDescrs) { 225 | if (descr.refIdx > 0) { 226 | references[descr.refIdx] = descr.parent[descr.key] 227 | descr.parent[descr.key] = EncodingTransformer._createRefMark( 228 | descr.refIdx 229 | ) 230 | } 231 | } 232 | 233 | return references 234 | } 235 | } 236 | 237 | // DecodingTransform 238 | class DecodingTransformer { 239 | references: any 240 | transformMap: any 241 | activeTransformsStack = [] as any 242 | visitedRefs = Object.create(null) 243 | 244 | constructor(references: any, transformsMap: any) { 245 | this.references = references 246 | this.transformMap = transformsMap 247 | } 248 | 249 | _handlePlainObject(obj: any) { 250 | const unescaped = Object.create(null) 251 | 252 | if ('constructor' in obj) { 253 | if (!obj.constructor || typeof obj.constructor.name !== 'string') { 254 | obj.constructor = { 255 | name: 'Object', 256 | } 257 | } 258 | } 259 | 260 | for (const key in obj) { 261 | if (obj.hasOwnProperty(key)) { 262 | this._handleValue(obj[key], obj, key) 263 | 264 | if (KEY_REQUIRE_ESCAPING_RE.test(key)) { 265 | // NOTE: use intermediate object to avoid unescaped and escaped keys interference 266 | // E.g. unescaped "##@t" will be "#@t" which can overwrite escaped "#@t". 267 | unescaped[key.substring(1)] = obj[key] 268 | delete obj[key] 269 | } 270 | } 271 | } 272 | 273 | for (const unsecapedKey in unescaped) 274 | obj[unsecapedKey] = unescaped[unsecapedKey] 275 | } 276 | 277 | _handleTransformedObject(obj: any, parent: any, key: any) { 278 | const transformType = obj[TRANSFORMED_TYPE_KEY] 279 | const transform = this.transformMap[transformType] 280 | 281 | if (!transform) 282 | throw new Error(`Can't find transform for "${transformType}" type.`) 283 | 284 | this.activeTransformsStack.push(obj) 285 | this._handleValue(obj.data, obj, 'data') 286 | this.activeTransformsStack.pop() 287 | 288 | parent[key] = transform.fromSerializable(obj.data) 289 | } 290 | 291 | _handleCircularSelfRefDuringTransform(refIdx: any, parent: any, key: any) { 292 | // NOTE: we've hit a hard case: object reference itself during transformation. 293 | // We can't dereference it since we don't have resulting object yet. And we'll 294 | // not be able to restore reference lately because we will need to traverse 295 | // transformed object again and reference might be unreachable or new object contain 296 | // new circular references. As a workaround we create getter, so once transformation 297 | // complete, dereferenced property will point to correct transformed object. 298 | const references = this.references 299 | 300 | Object.defineProperty(parent, key, { 301 | // @ts-ignore 302 | val: void 0, 303 | configurable: true, 304 | enumerable: true, 305 | 306 | get() { 307 | if (this.val === void 0) this.val = references[refIdx] 308 | 309 | return (this).val 310 | }, 311 | 312 | set(value) { 313 | this.val = value 314 | }, 315 | }) 316 | } 317 | 318 | _handleCircularRef(refIdx: any, parent: any, key: any) { 319 | if (this.activeTransformsStack.includes(this.references[refIdx])) 320 | this._handleCircularSelfRefDuringTransform(refIdx, parent, key) 321 | else { 322 | if (!this.visitedRefs[refIdx]) { 323 | this.visitedRefs[refIdx] = true 324 | this._handleValue(this.references[refIdx], this.references, refIdx) 325 | } 326 | 327 | parent[key] = this.references[refIdx] 328 | } 329 | } 330 | 331 | _handleValue(val: any, parent: any, key: any) { 332 | if (typeof val !== 'object' || val === null) return 333 | 334 | const refIdx = val[CIRCULAR_REF_KEY] 335 | 336 | if (refIdx !== void 0) this._handleCircularRef(refIdx, parent, key) 337 | else if (val[TRANSFORMED_TYPE_KEY]) 338 | this._handleTransformedObject(val, parent, key) 339 | else if (Array.isArray(val)) { 340 | for (let i = 0; i < val.length; i++) this._handleValue(val[i], val, i) 341 | } else this._handlePlainObject(val) 342 | } 343 | 344 | transform() { 345 | this.visitedRefs[0] = true 346 | this._handleValue(this.references[0], this.references, 0) 347 | 348 | return this.references[0] 349 | } 350 | } 351 | 352 | // Transforms 353 | const builtInTransforms = [ 354 | { 355 | type: '[[NaN]]', 356 | 357 | shouldTransform(type: any, val: any) { 358 | return type === 'number' && isNaN(val) 359 | }, 360 | 361 | toSerializable() { 362 | return '' 363 | }, 364 | 365 | fromSerializable() { 366 | return NaN 367 | }, 368 | }, 369 | 370 | { 371 | type: '[[undefined]]', 372 | 373 | shouldTransform(type: any) { 374 | return type === 'undefined' 375 | }, 376 | 377 | toSerializable() { 378 | return '' 379 | }, 380 | 381 | fromSerializable() { 382 | return void 0 383 | }, 384 | }, 385 | { 386 | type: '[[Date]]', 387 | 388 | lookup: Date, 389 | 390 | shouldTransform(type: any, val: any) { 391 | return val instanceof Date 392 | }, 393 | 394 | toSerializable(date: any) { 395 | return date.getTime() 396 | }, 397 | 398 | fromSerializable(val: any) { 399 | const date = new Date() 400 | 401 | date.setTime(val) 402 | return date 403 | }, 404 | }, 405 | { 406 | type: '[[RegExp]]', 407 | 408 | lookup: RegExp, 409 | 410 | shouldTransform(type: any, val: any) { 411 | return val instanceof RegExp 412 | }, 413 | 414 | toSerializable(re: any) { 415 | const result = { 416 | src: re.source, 417 | flags: '', 418 | } 419 | 420 | if (re.global) result.flags += 'g' 421 | 422 | if (re.ignoreCase) result.flags += 'i' 423 | 424 | if (re.multiline) result.flags += 'm' 425 | 426 | return result 427 | }, 428 | 429 | fromSerializable(val: any) { 430 | return new RegExp(val.src, val.flags) 431 | }, 432 | }, 433 | 434 | { 435 | type: '[[Error]]', 436 | 437 | lookup: Error, 438 | 439 | shouldTransform(type: any, val: any) { 440 | return val instanceof Error 441 | }, 442 | 443 | toSerializable(err: any) { 444 | if (!err.stack) { 445 | ;(Error as any).captureStackTrace?.(err) 446 | } 447 | 448 | return { 449 | name: err.name, 450 | message: err.message, 451 | stack: err.stack, 452 | } 453 | }, 454 | 455 | fromSerializable(val: any) { 456 | const Ctor = GLOBAL[val.name] || Error 457 | const err = new Ctor(val.message) 458 | 459 | err.stack = val.stack 460 | return err 461 | }, 462 | }, 463 | 464 | { 465 | type: '[[ArrayBuffer]]', 466 | 467 | lookup: ARRAY_BUFFER_SUPPORTED && ArrayBuffer, 468 | 469 | shouldTransform(type: any, val: any) { 470 | return ARRAY_BUFFER_SUPPORTED && val instanceof ArrayBuffer 471 | }, 472 | 473 | toSerializable(buffer: any) { 474 | const view = new Int8Array(buffer) 475 | 476 | return arrSlice.call(view) 477 | }, 478 | 479 | fromSerializable(val: any) { 480 | if (ARRAY_BUFFER_SUPPORTED) { 481 | const buffer = new ArrayBuffer(val.length) 482 | const view = new Int8Array(buffer) 483 | 484 | view.set(val) 485 | 486 | return buffer 487 | } 488 | 489 | return val 490 | }, 491 | }, 492 | 493 | { 494 | type: '[[TypedArray]]', 495 | 496 | shouldTransform(type: any, val: any) { 497 | if (ARRAY_BUFFER_SUPPORTED) { 498 | return ArrayBuffer.isView(val) && !(val instanceof DataView) 499 | } 500 | 501 | for (const ctorName of TYPED_ARRAY_CTORS) { 502 | if ( 503 | typeof GLOBAL[ctorName] === 'function' && 504 | val instanceof GLOBAL[ctorName] 505 | ) 506 | return true 507 | } 508 | 509 | return false 510 | }, 511 | 512 | toSerializable(arr: any) { 513 | return { 514 | ctorName: arr.constructor.name, 515 | arr: arrSlice.call(arr), 516 | } 517 | }, 518 | 519 | fromSerializable(val: any) { 520 | return typeof GLOBAL[val.ctorName] === 'function' 521 | ? new GLOBAL[val.ctorName](val.arr) 522 | : val.arr 523 | }, 524 | }, 525 | 526 | { 527 | type: '[[Map]]', 528 | 529 | lookup: MAP_SUPPORTED && Map, 530 | 531 | shouldTransform(type: any, val: any) { 532 | return MAP_SUPPORTED && val instanceof Map 533 | }, 534 | 535 | toSerializable(map: any) { 536 | const flattenedKVArr: any = [] 537 | 538 | map.forEach((val: any, key: any) => { 539 | flattenedKVArr.push(key) 540 | flattenedKVArr.push(val) 541 | }) 542 | 543 | return flattenedKVArr 544 | }, 545 | 546 | fromSerializable(val: any) { 547 | if (MAP_SUPPORTED) { 548 | // NOTE: new Map(iterable) is not supported by all browsers 549 | const map = new Map() 550 | 551 | for (var i = 0; i < val.length; i += 2) map.set(val[i], val[i + 1]) 552 | 553 | return map 554 | } 555 | 556 | const kvArr = [] 557 | 558 | // @ts-ignore 559 | for (let j = 0; j < val.length; j += 2) kvArr.push([val[i], val[i + 1]]) 560 | 561 | return kvArr 562 | }, 563 | }, 564 | 565 | { 566 | type: '[[Set]]', 567 | 568 | lookup: SET_SUPPORTED && Set, 569 | 570 | shouldTransform(type: any, val: any) { 571 | return SET_SUPPORTED && val instanceof Set 572 | }, 573 | 574 | toSerializable(set: any) { 575 | const arr: any = [] 576 | 577 | set.forEach((val: any) => { 578 | arr.push(val) 579 | }) 580 | 581 | return arr 582 | }, 583 | 584 | fromSerializable(val: any) { 585 | if (SET_SUPPORTED) { 586 | // NOTE: new Set(iterable) is not supported by all browsers 587 | const set = new Set() 588 | 589 | for (let i = 0; i < val.length; i++) set.add(val[i]) 590 | 591 | return set 592 | } 593 | 594 | return val 595 | }, 596 | }, 597 | ] 598 | 599 | // Replicator 600 | class Replicator { 601 | transforms = [] as any 602 | transformsMap = Object.create(null) 603 | serializer: any 604 | 605 | constructor(serializer?: any) { 606 | this.serializer = serializer || JSONSerializer 607 | 608 | this.addTransforms(builtInTransforms) 609 | } 610 | 611 | addTransforms(transforms: any) { 612 | transforms = Array.isArray(transforms) ? transforms : [transforms] 613 | 614 | for (const transform of transforms) { 615 | if (this.transformsMap[transform.type]) 616 | throw new Error( 617 | `Transform with type "${transform.type}" was already added.` 618 | ) 619 | 620 | this.transforms.push(transform) 621 | this.transformsMap[transform.type] = transform 622 | } 623 | 624 | return this 625 | } 626 | 627 | removeTransforms(transforms: any) { 628 | transforms = Array.isArray(transforms) ? transforms : [transforms] 629 | 630 | for (const transform of transforms) { 631 | const idx = this.transforms.indexOf(transform) 632 | 633 | if (idx > -1) this.transforms.splice(idx, 1) 634 | 635 | delete this.transformsMap[transform.type] 636 | } 637 | 638 | return this 639 | } 640 | 641 | encode(val: any, limit?: number) { 642 | const transformer = new EncodingTransformer(val, this.transforms, limit) 643 | const references = transformer.transform() 644 | 645 | return this.serializer.serialize(references) 646 | } 647 | 648 | decode(val: any) { 649 | const references = this.serializer.deserialize(val) 650 | const transformer = new DecodingTransformer(references, this.transformsMap) 651 | 652 | return transformer.transform() 653 | } 654 | } 655 | 656 | export default Replicator 657 | -------------------------------------------------------------------------------- /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/definitions/Component.d.ts: -------------------------------------------------------------------------------- 1 | import { Payload } from './Payload' 2 | import { Styles } from './Styles' 3 | import { Methods } from './Methods' 4 | import type { Options } from 'linkifyjs' 5 | import { ComponentOverrides } from './ComponentOverrides' 6 | 7 | export type Variants = 'light' | 'dark' 8 | 9 | export interface Theme { 10 | variant: Variants 11 | styles: Styles 12 | } 13 | 14 | export interface Context extends Theme { 15 | method: Methods 16 | } 17 | 18 | export interface Message extends Payload { 19 | data: any[] 20 | amount?: number 21 | } 22 | 23 | export interface Props { 24 | logs: Message[] 25 | variant?: Variants 26 | styles?: Styles 27 | filter?: Methods[] 28 | searchKeywords?: string 29 | logFilter?: Function 30 | logGrouping?: Boolean 31 | linkifyOptions?: Options 32 | components?: ComponentOverrides 33 | } 34 | 35 | export interface MessageProps { 36 | log: Message 37 | linkifyOptions?: Options 38 | components?: ComponentOverrides 39 | } 40 | -------------------------------------------------------------------------------- /src/definitions/ComponentOverrides.d.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react' 2 | import { Message } from './Component' 3 | 4 | export interface ComponentOverrides { 5 | Message?: ComponentType<{ 6 | node?: JSX.Element 7 | log?: Message 8 | children: React.ReactNode 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/definitions/Console.d.ts: -------------------------------------------------------------------------------- 1 | import { Methods as _Methods } from './Methods' 2 | import { Payload } from './Payload' 3 | 4 | export interface Storage { 5 | pointers: { 6 | [name: string]: Function 7 | } 8 | src: any 9 | } 10 | 11 | export interface HookedConsole extends Console { 12 | feed: Storage 13 | } 14 | 15 | export type Methods = _Methods 16 | 17 | export interface Message { 18 | method: Methods 19 | data?: any[] 20 | timestamp?: string 21 | } 22 | 23 | export type Callback = (encoded: Message, message: Payload) => void 24 | -------------------------------------------------------------------------------- /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 | 'command', 14 | 'result', 15 | 'dir', 16 | ] 17 | 18 | export default methods 19 | 20 | export type Methods = 21 | | 'log' 22 | | 'debug' 23 | | 'info' 24 | | 'warn' 25 | | 'error' 26 | | 'table' 27 | | 'clear' 28 | | 'time' 29 | | 'timeEnd' 30 | | 'count' 31 | | 'assert' 32 | | 'command' 33 | | 'result' 34 | | 'dir' 35 | -------------------------------------------------------------------------------- /src/definitions/Payload.d.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './Console' 2 | 3 | export interface Payload extends Message { 4 | id: string 5 | } 6 | -------------------------------------------------------------------------------- /src/definitions/Store.d.ts: -------------------------------------------------------------------------------- 1 | export interface Action { 2 | type: string 3 | [key: string]: any 4 | } 5 | -------------------------------------------------------------------------------- /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/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 | import { ComponentOverrides as _ComponentOverrides } from './definitions/ComponentOverrides' 6 | export type ComponentOverrides = _ComponentOverrides 7 | 8 | export { Decode } from './Transform' 9 | export { Encode } from './Transform' 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 | ] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "module": "commonjs", 5 | "target": "es3", 6 | "ignoreDeprecations": "5.0", 7 | "lib": ["es6", "dom", "ES2020.BigInt"], 8 | "inlineSourceMap": true, 9 | "skipDefaultLibCheck": true, 10 | "skipLibCheck": true, 11 | "jsx": "react", 12 | "moduleResolution": "node", 13 | "rootDir": ".", 14 | "forceConsistentCasingInFileNames": true, 15 | "types": ["jest"], 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------