Skip to content

Commit

Permalink
feat: add log streaming (#14)
Browse files Browse the repository at this point in the history
* feat: add streaming API

* chore: lint fix

* fix: fix error handling in streaming API

* fix: strip ANSI codes from stream

* fix: fix event listeners in dependencies add/remove

* feat: add color

* feat: add utf8-stream

* fix: switch back to pre

* fix: make sure output doesn't overflow

* feat: use ansi-html-stream

* feat: add rainbow test file
  • Loading branch information
azz committed Jan 5, 2018
1 parent 98fdecd commit e2fcaa9
Show file tree
Hide file tree
Showing 18 changed files with 235 additions and 55 deletions.
8 changes: 6 additions & 2 deletions package.json
Expand Up @@ -26,18 +26,22 @@
"build": "parcel build index.html --public-url ./",
"test": "jest",
"lint": "eslint . --ext=js,jsx --ignore-path=.gitignore",
"yes": "yes hello world"
"yes": "yes hello world",
"rainbow": "./test/rainbow.sh"
},
"dependencies": {
"ansi-html-stream": "^0.0.3",
"execa": "^0.8.0",
"fs-router": "^0.4.0",
"get-port": "^3.2.0",
"has-yarn": "^1.0.0",
"merge-stream": "^1.0.1",
"micro": "^9.0.2",
"micro-cors": "^0.0.4",
"mime": "^2.1.0",
"open": "^0.0.5",
"read-pkg-up": "^3.0.0"
"read-pkg-up": "^3.0.0",
"through2-map": "^3.0.0"
},
"devDependencies": {
"babel-eslint": "^8.1.2",
Expand Down
14 changes: 6 additions & 8 deletions routes/dependencies/add/:dependency.js
@@ -1,14 +1,12 @@
const { send } = require('micro');
const hasYarn = require('has-yarn');
const execa = require('execa');
const ansiCommandStream = require('../../../src/ansiCommandStream');

module.exports = async ({ params: { dependency }, on }, res) => {
module.exports = async (req, res) => {
const { params: { dependency } } = req;
try {
const command = hasYarn() ? 'yarn' : 'npm';
const { stdout, kill } = execa(command, ['add', dependency]);
stdout.pipe(process.stdout);
send(res, 200, stdout);
on('close', kill);
const { stream, kill } = ansiCommandStream({ args: ['add', dependency] });
send(res, 200, stream);
req.on('close', kill);
} catch (err) {
send(res, 404, err);
}
Expand Down
15 changes: 6 additions & 9 deletions routes/dependencies/remove/:dependency.js
@@ -1,15 +1,12 @@
const { send } = require('micro');
const hasYarn = require('has-yarn');
const execa = require('execa');
const ansiCommandStream = require('../../../src/ansiCommandStream');

module.exports = async ({ params: { dependency }, on }, res) => {
module.exports = async (req, res) => {
const { params: { dependency } } = req;
try {
const command = hasYarn() ? 'yarn' : 'npm';
const argument = hasYarn() ? 'remove' : 'rm';
const { stdout, kill } = execa(command, [argument, dependency]);
stdout.pipe(process.stdout);
send(res, 200, stdout);
on('close', kill);
const { stream, kill } = ansiCommandStream({ args: ['rm', dependency] });
send(res, 200, stream);
req.on('close', kill);
} catch (err) {
send(res, 404, err);
}
Expand Down
16 changes: 8 additions & 8 deletions routes/scripts/run/:script.js
@@ -1,15 +1,15 @@
const { send } = require('micro');
const hasYarn = require('has-yarn');
const execa = require('execa');
const ansiCommandStream = require('../../../src/ansiCommandStream');

module.exports = async ({ params: { script }, on }, res) => {
module.exports = async (req, res) => {
const { params: { script } } = req;
try {
const command = hasYarn() ? 'yarn' : 'npm';
const { stdout, kill } = execa(command, ['run', script]);
stdout.pipe(process.stdout);
send(res, 200, stdout);
on('close', kill);
const { stream, kill } = ansiCommandStream({ args: ['run', script] });
// stream.pipe(process.stdout);
send(res, 200, stream);
req.on('close', kill);
} catch (err) {
process.stderr.write(err.toString());
send(res, 404, err);
}
};
48 changes: 48 additions & 0 deletions src/ansiCommandStream.js
@@ -0,0 +1,48 @@
const hasYarn = require('has-yarn');
const execa = require('execa');
const merge = require('merge-stream');
const ansi = require('ansi-html-stream');

const theme = {
resets: {
'0': false,
},
bold: {
'1': { style: 'font-weight:bold' },
},
underline: {
'4': { style: 'text-decoration:underline' },
},
foregrounds: {
'30': { style: 'color:black' }, // black
'31': { style: 'color:red' }, // red
'32': { style: 'color:green' }, // green
'33': { style: 'color:#FDE541' }, // yellow
'34': { style: 'color:blue' }, // blue
'35': { style: 'color:magenta' }, // magenta
'36': { style: 'color:cyan' }, // cyan
'37': { style: 'color:white' }, // white
'39': false, // default
},
backgrounds: {
'40': { style: 'background-color:black' }, // black
'41': { style: 'background-color:red' }, // red
'42': { style: 'background-color:green' }, // green
'43': { style: 'background-color:yellow' }, // yellow
'44': { style: 'background-color:blue' }, // blue
'45': { style: 'background-color:magenta' }, // magenta
'46': { style: 'background-color:cyan' }, // cyan
'47': { style: 'background-color:white' }, // white
'49': false, // default
},
};

module.exports = function ansiCommandStream({ args }) {
const command = hasYarn() ? 'yarn' : 'npm';
const { stdout, stderr, kill } = execa(command, [
// '--color="always"',
...args,
]);
const stream = merge(stdout, stderr).pipe(ansi({ chunked: true, theme }));
return { stream, kill };
};
15 changes: 15 additions & 0 deletions test/rainbow.sh
@@ -0,0 +1,15 @@
#!/bin/bash

printf " "
for b in 0 1 2 3 4 5 6 7; do printf " 4${b}m "; done
echo
for f in "" 30 31 32 33 34 35 36 37; do
for s in "" "1;"; do
printf "%4sm" "${s}${f}"
printf " \033[%sm%s\033[0m" "$s$f" "gYw "
for b in 0 1 2 3 4 5 6 7; do
printf " \033[4%s;%sm%s\033[0m" "$b" "$s$f" " gYw "
done
echo
done
done
5 changes: 4 additions & 1 deletion ui/api/index.js
@@ -1 +1,4 @@
export const get = path => fetch(path).then(response => response.json());
export const getJson = path => fetch(path).then(response => response.json());

export const getStream = path =>
fetch(path).then(response => response.body.getReader());
5 changes: 3 additions & 2 deletions ui/components/ScriptList.jsx
Expand Up @@ -15,10 +15,10 @@ const Command = styled.div`
font-family: monospace;
`;

const ScriptList = ({ scripts }) => (
const ScriptList = ({ scripts, onScriptClick = () => {} }) => (
<Container>
{scripts.map(({ name, command }) => (
<Section key={name}>
<Section key={name} onClick={() => onScriptClick(name)}>
<Heading>{name}</Heading>
<Command>{command}</Command>
</Section>
Expand All @@ -33,6 +33,7 @@ ScriptList.propTypes = {
command: PropTypes.string.isRequired,
}),
),
onScriptClick: PropTypes.func,
};

export default ScriptList;
33 changes: 24 additions & 9 deletions ui/components/Scripts.jsx
@@ -1,23 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

import { selectors } from '../ducks/scripts';
import { selectors, actions } from '../ducks/scripts';
import ScriptList from './ScriptList';
import { Grid } from './styled';
const Scripts = ({ scripts }) => (
import { Grid, Log, Wrapper } from './styled';

const Scripts = ({ scripts, log, onScriptClick }) => (
<Grid col="2">
<ScriptList scripts={scripts} />
<div>
<ScriptList scripts={scripts} onScriptClick={onScriptClick} />
<Wrapper>
<h3>Terminal output:</h3>
</div>
<Log dangerouslySetInnerHTML={{ __html: log }} />
</Wrapper>
</Grid>
);

Scripts.propTypes = {
scripts: PropTypes.array.isRequired,
log: PropTypes.string.isRequired,
onScriptClick: PropTypes.func.isRequired,
};

export default connect(state => ({
scripts: selectors.getScripts(state.scripts),
}))(Scripts);
export default connect(
state => ({
scripts: selectors.getScripts(state.scripts),
log: selectors.getLog(state.scripts),
}),
dispatch =>
bindActionCreators(
{
onScriptClick: actions.runScript,
},
dispatch,
),
)(Scripts);
4 changes: 1 addition & 3 deletions ui/components/Search.jsx
Expand Up @@ -10,9 +10,7 @@ import {
const Hit = ({ hit: { name } }) => <div>{name}</div>;

Hit.propTypes = {
hit: {
name: PropTypes.string,
},
hit: PropTypes.object.isRequired,
};

const Search = () => (
Expand Down
Expand Up @@ -6,6 +6,7 @@ exports[`<ScriptList /> with scripts 1`] = `
<styled.div>
<styled.section
key="test"
onClick={[Function]}
>
<styled.h2>
test
Expand All @@ -16,6 +17,7 @@ exports[`<ScriptList /> with scripts 1`] = `
</styled.section>
<styled.section
key="lint"
onClick={[Function]}
>
<styled.h2>
lint
Expand Down
9 changes: 9 additions & 0 deletions ui/components/styled.js
Expand Up @@ -3,3 +3,12 @@ export const Grid = styled.div`
display: grid;
grid-template-columns: repeat(${props => props.col}, 1fr);
`;

export const Wrapper = styled.section`
overflow: auto;
`;

export const Log = styled.pre`
overflow: auto;
padding-bottom: 1em;
`;
18 changes: 18 additions & 0 deletions ui/ducks/scripts.js
@@ -1,9 +1,13 @@
const initialState = {
packageScripts: {},
runningScriptName: null,
runningScriptLog: '',
};

export const FETCH_SCRIPTS = 'FETCH_SCRIPTS';
export const SET_SCRIPTS = 'SET_SCRIPTS';
export const RUN_SCRIPT = 'RUN_SCRIPT';
export const SCRIPT_LOG_APPEND = 'SCRIPT_LOG_APPEND';

export default (state = initialState, { type, ...payload }) => {
switch (type) {
Expand All @@ -12,6 +16,17 @@ export default (state = initialState, { type, ...payload }) => {
...state,
packageScripts: payload.scripts,
};
case RUN_SCRIPT:
return {
...state,
runningScriptName: payload.name,
runningScriptLog: '',
};
case SCRIPT_LOG_APPEND:
return {
...state,
runningScriptLog: state.runningScriptLog + payload.text,
};
default:
return state;
}
Expand All @@ -20,6 +35,8 @@ export default (state = initialState, { type, ...payload }) => {
export const actions = {
fetchScripts: () => ({ type: FETCH_SCRIPTS }),
setScripts: scripts => ({ type: SET_SCRIPTS, scripts }),
runScript: name => ({ type: RUN_SCRIPT, name }),
scriptLogAppend: text => ({ type: SCRIPT_LOG_APPEND, text }),
};

export const selectors = {
Expand All @@ -28,4 +45,5 @@ export const selectors = {
name,
command: state.packageScripts[name],
})),
getLog: state => state.runningScriptLog,
};
4 changes: 2 additions & 2 deletions ui/sagas/dependencies.js
@@ -1,9 +1,9 @@
import { call, put } from 'redux-saga/effects';

import { actions } from '../ducks/dependencies';
import { get } from '../api';
import { getJson } from '../api';

export function* fetchDependencies() {
const dependencies = yield call(get, '/dependencies');
const dependencies = yield call(getJson, '/dependencies');
yield put(actions.setDependencies(dependencies));
}
15 changes: 8 additions & 7 deletions ui/sagas/index.js
@@ -1,13 +1,14 @@
import { takeLatest } from 'redux-saga/effects';
import { takeLatest, fork } from 'redux-saga/effects';

import { FETCH_SCRIPTS } from '../ducks/scripts';
import { fetchScripts } from './scripts';
import { FETCH_SCRIPTS, RUN_SCRIPT } from '../ducks/scripts';
import { fetchScripts, runScript } from './scripts';
import { FETCH_DEPENDENCIES } from '../ducks/dependencies';
import { fetchDependencies } from './dependencies';

export default function* index() {
yield* fetchScripts();
yield* fetchDependencies();
takeLatest(FETCH_SCRIPTS, fetchScripts);
takeLatest(FETCH_DEPENDENCIES, fetchDependencies);
yield fork(fetchScripts);
yield fork(fetchDependencies);
yield takeLatest(FETCH_SCRIPTS, fetchScripts);
yield takeLatest(RUN_SCRIPT, runScript);
yield takeLatest(FETCH_DEPENDENCIES, fetchDependencies);
}
10 changes: 8 additions & 2 deletions ui/sagas/scripts.js
@@ -1,9 +1,15 @@
import { call, put } from 'redux-saga/effects';

import { actions } from '../ducks/scripts';
import { get } from '../api';
import { getJson, getStream } from '../api';
import { streamToDispatch } from './utils';

export function* fetchScripts() {
const { scripts } = yield call(get, '/scripts');
const { scripts } = yield call(getJson, '/scripts');
yield put(actions.setScripts(scripts));
}

export function* runScript({ name }) {
const reader = yield call(getStream, `/scripts/run/${name}`);
yield* streamToDispatch(reader, actions.scriptLogAppend);
}
10 changes: 10 additions & 0 deletions ui/sagas/utils.js
@@ -0,0 +1,10 @@
import { call, put } from 'redux-saga/effects';

export function* streamToDispatch(reader, actionCreator) {
while (true) {
const result = yield call([reader, reader.read]);
if (result.done) break;
const text = String.fromCodePoint(...result.value);
yield put(actionCreator(text));
}
}

0 comments on commit e2fcaa9

Please sign in to comment.