Created
August 14, 2018 21:46
-
-
Save superhawk610/99101bc6f927e703ad5995bc20a21668 to your computer and use it in GitHub Desktop.
Proposed conflict resolution for tasks middleware
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @flow | |
import { ipcRenderer } from 'electron'; | |
import * as childProcess from 'child_process'; | |
import { | |
RUN_TASK, | |
ABORT_TASK, | |
COMPLETE_TASK, | |
LAUNCH_DEV_SERVER, | |
completeTask, | |
attachTaskMetadata, | |
receiveDataFromTaskExecution, | |
loadDependencyInfoFromDisk, | |
} from '../actions'; | |
import { getProjectById } from '../reducers/projects.reducer'; | |
import { getPathForProjectId } from '../reducers/paths.reducer'; | |
import { isDevServerTask } from '../reducers/tasks.reducer'; | |
import findAvailablePort from '../services/find-available-port.service'; | |
import { isWin, getPathForPlatform } from '../services/platform.services'; | |
import type { Task, ProjectType } from '../types'; | |
import { PACKAGE_MANAGER_CMD } from '../services/platform.services'; | |
export default (store: any) => (next: any) => (action: any) => { | |
if (!action.task) { | |
return next(action); | |
} | |
const { task } = action; | |
const state = store.getState(); | |
const project = getProjectById(state, task.projectId); | |
const projectPath = getPathForProjectId(state, task.projectId); | |
// eslint-disable-next-line default-case | |
switch (action.type) { | |
case LAUNCH_DEV_SERVER: { | |
findAvailablePort() | |
.then(port => { | |
const { args, env } = getDevServerCommand(task, project.type, port); | |
const child = childProcess.spawn(PACKAGE_MANAGER_CMD, args, { | |
cwd: projectPath, | |
env: { | |
...window.process.env, | |
...env, | |
}, | |
}); | |
// Now that we have a port/processId for the server, attach it to | |
// the task. The port is used for opening the app, the pid is used | |
// to kill the process | |
next(attachTaskMetadata(task, child.pid, port)); | |
ipcRenderer.send('addProcessId', child.pid); | |
child.stdout.on('data', data => { | |
// Ok so, unfortunately, failure-to-compile is still pushed | |
// through stdout, not stderr. We want that message specifically | |
// to trigger an error state, and so we need to parse it. | |
const text = data.toString(); | |
const isError = text.includes('Failed to compile.'); | |
next(receiveDataFromTaskExecution(task, text, isError)); | |
}); | |
child.stderr.on('data', data => { | |
next(receiveDataFromTaskExecution(task, data.toString())); | |
}); | |
child.on('exit', code => { | |
// For Windows Support | |
// Windows sends code 1 (I guess its because we foce kill??) | |
const successfulCode = isWin() ? 1 : 0; | |
const wasSuccessful = code === successfulCode || code === null; | |
const timestamp = new Date(); | |
store.dispatch(completeTask(task, timestamp, wasSuccessful)); | |
}); | |
}) | |
.catch(err => { | |
// TODO: Error handling (this can happen if the first 15 ports are | |
// occupied, or if there's some generic Node error) | |
console.error(err); | |
}); | |
break; | |
} | |
// TODO: As tasks start to get more customized for the project types, | |
// it probably makes sense to have separate actions (eg. RUN_TESTS, | |
// BUILD_FOR_PRODUCTION), and use RUN_TASK just for user-added tasks. | |
case RUN_TASK: { | |
const { projectId, name } = action.task; | |
const project = getProjectById(store.getState(), projectId); | |
// TEMPORARY HACK | |
// By default, create-react-app runs tests in interactive watch mode. | |
// This is a brilliant way to do it, but it's interactive, which won't | |
// work as-is. | |
// In the future, I expect "Tests" to get its own module on the project | |
// page, in which case we can support the interactive mode, except with | |
// descriptive buttons instead of cryptic letters! | |
// Alas, this would be mucho work, and this is an MVP. So for now, I'm | |
// disabling watch mode, and doing "just run all the tests once" mode. | |
// This is bad, and I feel bad, but it's a corner that needs to be cut, | |
// for now. | |
const additionalArgs = []; | |
if (project.type === 'create-react-app' && name === 'test') { | |
additionalArgs.push('--', '--coverage'); | |
} | |
/* Bypasses 'Are you sure?' check when ejecting CRA | |
*/ | |
const child = childProcess.spawn( | |
PACKAGE_MANAGER_CMD, | |
['run', name, ...additionalArgs], | |
{ | |
cwd: projectPath, | |
env: { | |
...window.process.env, | |
}, | |
} | |
); | |
// When this application exits, we want to kill this process. | |
// Send it up to the main process. | |
ipcRenderer.send('addProcessId', child.pid); | |
// TODO: Does the renderer process still need to know about the child | |
// processId? | |
next(attachTaskMetadata(task, child.pid)); | |
child.stdout.on('data', data => { | |
// The 'eject' task prompts the user, to ask if they're sure. | |
// We can bypass this prompt, as our UI already has an alert that | |
// confirms this action. | |
// TODO: Eject deserves its own Redux action, to avoid cluttering up | |
// this generic "RUN_TASK" action. | |
// TODO: Is there a way to "future-proof" this, in case the CRA | |
// confirmation copy changes? | |
const isEjectPrompt = data | |
.toString() | |
.includes('Are you sure you want to eject? This action is permanent'); | |
if (isEjectPrompt) { | |
sendCommandToProcess(child, 'y'); | |
} | |
next(receiveDataFromTaskExecution(task, data.toString())); | |
}); | |
child.stderr.on('data', data => { | |
next(receiveDataFromTaskExecution(task, data.toString())); | |
}); | |
child.on('exit', code => { | |
const timestamp = new Date(); | |
store.dispatch(completeTask(task, timestamp, code === 0)); | |
if (task.name === 'eject') { | |
store.dispatch(loadDependencyInfoFromDisk(project.id, project.path)); | |
} | |
}); | |
break; | |
} | |
case ABORT_TASK: { | |
const { task } = action; | |
const { processId, name } = task; | |
childProcess.spawn('kill', ['-9', processId]); | |
ipcRenderer.send('removeProcessId', processId); | |
// Once the task is killed, we should dispatch a notification | |
// so that the terminal shows something about this update. | |
// My initial thought was that all tasks would have the same message, | |
// but given that we're treating `start` as its own special thing, | |
// I'm realizing that it should vary depending on the task type. | |
// TODO: Find a better place for this to live. | |
const abortMessage = isDevServerTask(name) | |
? 'Server stopped' | |
: 'Task aborted'; | |
next( | |
receiveDataFromTaskExecution( | |
task, | |
`\u001b[31;1m${abortMessage}\u001b[0m` | |
) | |
); | |
break; | |
} | |
case COMPLETE_TASK: { | |
const { task } = action; | |
// Send a message to add info to the terminal about the task being done. | |
// TODO: ASCII fish art? | |
const message = 'Task completed'; | |
next( | |
receiveDataFromTaskExecution(task, `\u001b[32;1m${message}\u001b[0m`) | |
); | |
if (task.processId) { | |
ipcRenderer.send('removeProcessId', task.processId); | |
} | |
// The `eject` task is special; after running it, its dependencies will | |
// have changed. | |
// TODO: We should really have a `EJECT_PROJECT_COMPLETE` action that does | |
// this instead. | |
if (task.name === 'eject') { | |
const project = getProjectById(store.getState(), task.projectId); | |
store.dispatch(loadDependencyInfoFromDisk(project.id, project.path)); | |
} | |
break; | |
} | |
} | |
// Pass all actions through, unless the function returns early (which happens | |
// when deferring the 'eject' task) | |
return next(action); | |
}; | |
const getDevServerCommand = ( | |
task: Task, | |
projectType: ProjectType, | |
port: string | |
) => { | |
switch (projectType) { | |
case 'create-react-app': | |
return { | |
args: ['run', task.name], | |
env: { | |
PORT: port, | |
}, | |
}; | |
case 'gatsby': | |
return { | |
args: ['run', task.name, '--', `-p ${port}`], | |
env: {}, | |
}; | |
default: | |
throw new Error('Unrecognized project type: ' + projectType); | |
} | |
}; | |
const sendCommandToProcess = (child: any, command: string) => { | |
// Commands have to be suffixed with '\n' to signal that the command is | |
// ready to be sent. Same as a regular command + hitting the enter key. | |
child.stdin.write(`${command}\n`); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment