Skip to content

Instantly share code, notes, and snippets.

@sompylasar
Last active March 30, 2017 09:01
Show Gist options
  • Save sompylasar/5e7157e451f4b7268def9ae1ce01edd4 to your computer and use it in GitHub Desktop.
Save sompylasar/5e7157e451f4b7268def9ae1ce01edd4 to your computer and use it in GitHub Desktop.
Server-side rendering loop for universal sagas (redux-saga)
export default function createActionMonitor({
debug = false,
}) {
let actionMonitorLog = [];
const actionMonitorSubscriptions = [];
const middleware = () => (next) => (action) => {
if ( debug ) {
console.log('[actionMonitor] push:', action); // eslint-disable-line
}
actionMonitorLog = actionMonitorLog.concat([ {
...action,
// Clear the meta for equality comparison, it may contain the timestamp.
meta: null,
} ]);
actionMonitorSubscriptions.forEach(fn => fn());
return next(action);
};
const actionMonitor = {
middleware() {
return middleware;
},
getActions() {
return actionMonitorLog;
},
reset() {
if ( debug ) {
console.log('[actionMonitor] reset'); // eslint-disable-line
}
actionMonitorLog = [];
},
subscribe(fn) {
if (typeof fn !== 'function') {
throw new Error('[actionMonitor] `subscribe` expects a function.');
}
actionMonitorSubscriptions.push(fn);
return () => {
const index = actionMonitorSubscriptions.indexOf(fn);
if (index >= 0) {
actionMonitorSubscriptions.splice(index, 1);
}
};
},
};
return actionMonitor;
}
/*
eslint-disable
semi,
no-else-return,
no-console,
no-use-before-define,
brace-style,
keyword-spacing,
no-nested-ternary,
no-multi-spaces,
id-length,
object-curly-spacing,
one-var,
one-var-declaration-per-line,
array-bracket-spacing,
*/
import { is, asEffect } from 'redux-saga/utils'
export const EFFECT_STATUS_PENDING = 'PENDING'
export const EFFECT_STATUS_RESOLVED = 'RESOLVED'
export const EFFECT_STATUS_REJECTED = 'REJECTED'
export const EFFECT_STATUS_CANCELLED = 'CANCELLED'
const DEFAULT_STYLE = 'color: black'
const LABEL_STYLE = 'font-weight: bold'
const EFFECT_TYPE_STYLE = 'color: blue'
const ERROR_STYLE = 'color: red'
const CANCEL_STYLE = 'color: #ccc'
const IS_BROWSER = (typeof window !== 'undefined' && window.document)
export default function createSagaMonitor({
debug = false,
exportToWindow = true,
} = {}) {
const VERBOSE = debug;
const time = () => {
if(typeof performance !== 'undefined' && performance.now)
return performance.now()
else
return Date.now()
}
const effectsById = {}
function effectTriggered(desc) {
if (VERBOSE)
console.log('Saga monitor: effectTriggered:', desc)
effectsById[desc.effectId] = Object.assign({},
desc,
{
status: EFFECT_STATUS_PENDING,
start: time()
}
)
}
function effectResolved(effectId, result) {
if (VERBOSE)
console.log('Saga monitor: effectResolved:', effectId, result)
resolveEffect(effectId, result)
}
function effectRejected(effectId, error) {
if (VERBOSE)
console.log('Saga monitor: effectRejected:', effectId, error)
rejectEffect(effectId, error)
}
function effectCancelled(effectId) {
if (VERBOSE)
console.log('Saga monitor: effectCancelled:', effectId)
cancelEffect(effectId)
}
function computeEffectDur(effect) {
const now = time()
Object.assign(effect, {
end: now,
duration: now - effect.start
})
}
function resolveEffect(effectId, result) {
const effect = effectsById[effectId]
if(is.task(result)) {
result.done.then(
taskResult => {
if(result.isCancelled())
cancelEffect(effectId)
else
resolveEffect(effectId, taskResult)
},
taskError => rejectEffect(effectId, taskError)
)
} else {
computeEffectDur(effect)
effect.status = EFFECT_STATUS_RESOLVED
effect.result = result
if(effect && asEffect.race(effect.effect))
setRaceWinner(effectId, result)
}
}
function rejectEffect(effectId, error) {
const effect = effectsById[effectId]
computeEffectDur(effect)
effect.status = EFFECT_STATUS_REJECTED
effect.error = error
if(effect && asEffect.race(effect.effect))
setRaceWinner(effectId, error)
}
function cancelEffect(effectId) {
const effect = effectsById[effectId]
computeEffectDur(effect)
effect.status = EFFECT_STATUS_CANCELLED
}
function setRaceWinner(raceEffectId, result) {
const winnerLabel = Object.keys(result)[0]
const children = getChildEffects(raceEffectId)
for(let i = 0; i < children.length; i++) {
const childEffect = effectsById[ children[i] ]
if (childEffect.label === winnerLabel)
childEffect.winner = true
}
}
function getChildEffects(parentEffectId) {
return Object.keys(effectsById)
.filter(effectId => effectsById[effectId].parentEffectId === parentEffectId)
.map(effectId => +effectId)
}
// Poor man's `console.group` and `console.groupEnd` for Node.
// Can be overridden by the `console-group` polyfill.
// The poor man's groups look nice, too, so whether to use
// the polyfilled methods or the hand-made ones can be made a preference.
let groupPrefix = '';
const GROUP_SHIFT = ' ';
const GROUP_ARROW = '▼';
function consoleGroup(...args) {
if(console.group)
console.group(...args)
else {
console.log('')
console.log(groupPrefix + GROUP_ARROW, ...args)
groupPrefix += GROUP_SHIFT
}
}
function consoleGroupEnd() {
if(console.groupEnd)
console.groupEnd()
else
groupPrefix = groupPrefix.substr(0, groupPrefix.length - GROUP_SHIFT.length)
}
function logEffectTree(effectId) {
const effect = effectsById[effectId]
if(effectId === undefined) {
console.log(groupPrefix, 'Saga monitor: No effect data for', effectId)
return
}
const childEffects = getChildEffects(effectId)
if(!childEffects.length)
logSimpleEffect(effect)
else {
if(effect) {
const {formatter} = getEffectLog(effect)
consoleGroup(...formatter.getLog())
} else
consoleGroup('root')
childEffects.forEach(logEffectTree)
consoleGroupEnd()
}
}
function logSimpleEffect(effect) {
const {method, formatter} = getEffectLog(effect)
console[method](...formatter.getLog())
}
/* eslint-disable no-cond-assign */
function getEffectLog(effect) {
let data, log
if(data = asEffect.take(effect.effect)) {
log = getLogPrefix('take', effect)
log.formatter.addValue(data)
logResult(effect, log.formatter)
}
else if(data = asEffect.put(effect.effect)) {
log = getLogPrefix('put', effect)
logResult(Object.assign({}, effect, { result: data }), log.formatter)
}
else if(data = asEffect.call(effect.effect)) {
log = getLogPrefix('call', effect)
log.formatter.addCall(data.fn.name, data.args)
logResult(effect, log.formatter)
}
else if(data = asEffect.cps(effect.effect)) {
log = getLogPrefix('cps', effect)
log.formatter.addCall(data.fn.name, data.args)
logResult(effect, log.formatter)
}
else if(data = asEffect.fork(effect.effect)) {
log = getLogPrefix('', effect)
log.formatter.addCall(data.fn.name, data.args)
logResult(effect, log.formatter)
}
else if(data = asEffect.join(effect.effect)) {
log = getLogPrefix('join', effect)
logResult(effect, log.formatter)
}
else if(data = asEffect.race(effect.effect)) {
log = getLogPrefix('race', effect)
logResult(effect, log.formatter, true)
}
else if(data = asEffect.cancel(effect.effect)) {
log = getLogPrefix('cancel', effect)
log.formatter.appendData(data.name)
}
else if(data = asEffect.select(effect.effect)) {
log = getLogPrefix('select', effect)
log.formatter.addCall(data.selector.name, data.args)
logResult(effect, log.formatter)
}
else if(is.array(effect.effect)) {
log = getLogPrefix('parallel', effect)
logResult(effect, log.formatter, true)
}
else if(is.iterator(effect.effect)) {
log = getLogPrefix('', effect)
log.formatter.addValue(effect.effect.name)
logResult(effect, log.formatter, true)
}
else {
log = getLogPrefix('unkown', effect)
logResult(effect, log.formatter)
}
return log
}
function getLogPrefix(type, effect) {
const isCancel = effect.status === EFFECT_STATUS_CANCELLED
const isError = effect.status === EFFECT_STATUS_REJECTED
const method = isError ? 'error' : 'log'
const winnerInd = effect && effect.winner
? ( isError ? '✘' : '✓' )
: ''
const style = (s) => (
isCancel ? CANCEL_STYLE
: isError ? ERROR_STYLE
: s
)
const formatter = logFormatter()
if(winnerInd)
formatter.add(`%c ${winnerInd}`, style(LABEL_STYLE))
if(effect && effect.label)
formatter.add(`%c ${effect.label}: `, style(LABEL_STYLE))
if(type)
formatter.add(`%c ${type} `, style(EFFECT_TYPE_STYLE))
formatter.add('%c', style(DEFAULT_STYLE))
return {
method,
formatter
}
}
function argToString(arg) {
return (
typeof arg === 'function' ? `${arg.name}`
: typeof arg === 'string' ? `'${arg}'`
: arg
)
}
function logResult({status, result, error, duration}, formatter, ignoreResult) {
if(status === EFFECT_STATUS_RESOLVED && !ignoreResult) {
if( is.array(result) ) {
formatter.addValue(' → ')
formatter.addValue(result)
} else
formatter.appendData('→', result)
}
else if(status === EFFECT_STATUS_REJECTED) {
formatter.appendData('→ ⚠', error)
}
else if(status === EFFECT_STATUS_PENDING)
formatter.appendData('⌛')
else if(status === EFFECT_STATUS_CANCELLED)
formatter.appendData('→ Cancelled!')
if(status !== EFFECT_STATUS_PENDING)
formatter.appendData(`(${duration.toFixed(2)}ms)`)
}
function isPrimitive(val) {
return typeof val === 'string' ||
typeof val === 'number' ||
typeof val === 'boolean' ||
typeof val === 'symbol' ||
val === null ||
val === undefined;
}
function logFormatter() {
const logs = []
let suffix = []
function add(msg, ...args) {
// Remove the `%c` CSS styling that is not supported by the Node console.
if(!IS_BROWSER && typeof msg === 'string') {
const prevMsg = msg
msg = msg.replace(/^%c\s*/, '')
if(msg !== prevMsg) {
// Remove the first argument which is the CSS style string.
args.shift()
}
}
logs.push({msg, args})
}
function appendData(...data) {
suffix = suffix.concat(data)
}
function addValue(value) {
if(isPrimitive(value))
add(value)
else {
// The browser console supports `%O`, the Node console does not.
if(IS_BROWSER)
add('%O', value)
else
add('%s', require('util').inspect(value))
}
}
function addCall(name, args) {
if(!args.length)
add( `${name}()` )
else {
add(name)
add('(')
args.forEach( (arg, i) => {
addValue( argToString(arg) )
addValue( i === args.length - 1 ? ')' : ', ')
})
}
}
function getLog() {
const msgs = []
let msgsArgs = []
for(let i = 0; i < logs.length; i++) {
msgs.push(logs[i].msg)
msgsArgs = msgsArgs.concat(logs[i].args)
}
return [msgs.join('')].concat(msgsArgs).concat(suffix)
}
return {
add, addValue, addCall, appendData, getLog
}
}
/**
* The snapshot-logging function for arbitrary use by external code.
*/
const logSaga = () => {
console.log('')
console.log('Saga monitor:', Date.now(), (new Date()).toISOString())
logEffectTree(0)
console.log('')
}
/**
* Returns all the effects hashed by identifier.
*
* @return {Object<string,SagaMonitorEffectDescriptor>} The effects hashed by identifier.
*/
const getEffectsById = () => {
return effectsById;
}
/**
* Returns effects that currently block the saga promise from resolving.
*
* @return {Array<SagaMonitorEffectDescriptor>} The effects array.
*/
const getBlockingEffectsArray = () => {
// Skip FORK and TAKE because they are auto-interrupted by the END action.
const blockingEffects = Object.keys(effectsById)
.filter(effectId => (
effectsById[effectId].status === EFFECT_STATUS_PENDING &&
!asEffect.fork(effectsById[effectId].effect) && !asEffect.take(effectsById[effectId].effect)
))
.map(effectId => effectsById[effectId])
return blockingEffects;
}
// The `sagaMonitor` to pass to the middleware.
const sagaMonitor = {
// Middleware interface.
effectTriggered,
effectResolved,
effectRejected,
effectCancelled,
// Utility functions.
getEffectsById,
getBlockingEffectsArray,
logSaga,
};
// Export the snapshot-logging function to run from the browser console or extensions.
if(IS_BROWSER && exportToWindow) {
window.$$LogSagas = logSaga
}
return sagaMonitor;
}
import ReactDOM from 'react-dom/server';
import lodashIsEqual from 'lodash/isEqual';
/**
* The render loop ensures all server-side processing has finished,
* and the application has reached a stable state.
*
* This technique enables specifying data demands in `componentWillMount`
* as dispatches of data request actions which are processed in sagas
* the same way this is done on the browser-side.
*
* @param {ReduxStore} store Must have `endSaga`, `actionMonitor`, `sagaMonitor`.
* @param {ReactElement} renderDOM The element to render to string.
* @param {Promise} options.sagaPromise The root saga `Task` promise.
* @param {number} [options.maxRenders=15] The limit for the number of server-side renders.
* @param {number} [options.timeout=5000] The timeout for the render to finish.
* @param {boolean} [options.debug=false] Enable debug log output.
* @return {Promise<string, Error>} Resolves to the rendered HTML string.
*/
function runRenderLoop({
store,
renderDOM,
sagaPromise,
maxRenders = 15,
timeout = 5000,
debug = false,
}) {
if ( !store || !store.endSaga || !store.actionMonitor || !store.sagaMonitor ) {
throw new Error('The `store` must have `endSaga`, `actionMonitor`, `sagaMonitor`.');
}
if ( !sagaPromise || !sagaPromise.then ) {
throw new Error('The `sagaPromise` must be a Promise.');
}
return new Promise((resolve, reject) => {
const serverRenderLoopContext = {
debug: debug,
blockingEffectsCountInitial: store.sagaMonitor.getBlockingEffectsArray().length,
actionsDispatchedDuringRender: [],
renderedString: '',
unsubscribe: null,
serverRenderLoopTimer: null,
rendersRemaining: maxRenders,
renderIsActive: false,
terminated: false,
timeoutTimer: null,
};
function debugLog(...args) {
if ( serverRenderLoopContext.debug ) {
console.log.apply(console, args); // eslint-disable-line no-console
}
}
function cancelRenderLoopFrame() {
clearTimeout(serverRenderLoopContext.serverRenderLoopTimer);
serverRenderLoopContext.serverRenderLoopTimer = null;
}
function requestRenderLoopFrame(fn) {
// Debounce to prevent re-render until all the synchronously dispatched actions are processed.
clearTimeout(serverRenderLoopContext.serverRenderLoopTimer);
serverRenderLoopContext.serverRenderLoopTimer = setTimeout(() => {
serverRenderLoopContext.serverRenderLoopTimer = null;
// Do not continue if currently rendering.
if ( serverRenderLoopContext.renderIsActive ) {
debugLog('[serverRenderLoop] renderIsActive in requestRenderLoopFrame setTimeout');
return;
}
// Do not continue if already terminated.
if ( serverRenderLoopContext.terminated ) {
debugLog('[serverRenderLoop] terminated in requestRenderLoopFrame setTimeout');
return;
}
fn();
}, 1);
}
function terminate() {
if ( serverRenderLoopContext.terminated ) {
debugLog('[serverRenderLoop] terminated in terminate');
return;
}
debugLog('[serverRenderLoop] terminate executing...');
serverRenderLoopContext.terminated = true;
const {
timeoutTimer,
unsubscribe,
} = serverRenderLoopContext;
clearTimeout(timeoutTimer);
if ( unsubscribe ) {
unsubscribe();
}
cancelRenderLoopFrame();
// Dispatch the saga END action.
store.endSaga();
if ( serverRenderLoopContext.debug ) {
debugLog('[serverRenderLoop] terminate done, waiting saga...');
}
// Wait for the saga to terminate.
sagaPromise.then(() => {
store.actionMonitor.reset();
debugLog('[serverRenderLoop] saga done, resolving...');
resolve(serverRenderLoopContext.renderedString);
debugLog('[serverRenderLoop] resolved.');
}).catch((error) => {
debugLog('[serverRenderLoop] saga error, rejecting...', error);
reject(error);
debugLog('[serverRenderLoop] rejected.');
});
}
const renderLoop = () => {
requestRenderLoopFrame(() => {
// Check if different actions have been dispatched during render.
const actionsDispatchedDuringRenderPrev = serverRenderLoopContext.actionsDispatchedDuringRender;
const actionsDispatchedDuringRender = store.actionMonitor.getActions();
const hasNewActions = (
actionsDispatchedDuringRender.length > 0 &&
!lodashIsEqual(actionsDispatchedDuringRenderPrev, actionsDispatchedDuringRender)
);
serverRenderLoopContext.actionsDispatchedDuringRender = actionsDispatchedDuringRender;
debugLog(
'[serverRenderLoop] hasNewActions:', hasNewActions,
'\n[serverRenderLoop] actionsDispatchedDuringRenderPrev:', actionsDispatchedDuringRenderPrev,
'\n[serverRenderLoop] actionsDispatchedDuringRender:', actionsDispatchedDuringRender,
'\n'
);
if ( hasNewActions ) {
serverRenderLoopContext.rendersRemaining--;
debugLog('[serverRenderLoop] rendersRemaining decremented:', serverRenderLoopContext.rendersRemaining);
// Reset action monitor for this render.
store.actionMonitor.reset();
debugLog('[serverRenderLoop] renderToString begin');
serverRenderLoopContext.renderIsActive = true;
serverRenderLoopContext.renderedString = ReactDOM.renderToString( renderDOM );
serverRenderLoopContext.renderIsActive = false;
debugLog('[serverRenderLoop] renderToString end');
}
const rendersRemaining = serverRenderLoopContext.rendersRemaining;
debugLog('[serverRenderLoop] rendersRemaining:', rendersRemaining);
// Check if blocking effects are active.
const blockingEffects = store.sagaMonitor.getBlockingEffectsArray();
const blockingEffectsCount = blockingEffects.length;
const blockingEffectsCountInitial = serverRenderLoopContext.blockingEffectsCountInitial;
const hasBlockingEffects = (blockingEffectsCount > blockingEffectsCountInitial);
debugLog(
'[serverRenderLoop] hasBlockingEffects:', hasBlockingEffects,
'\n[serverRenderLoop] blockingEffects:', blockingEffects,
'\n[serverRenderLoop] blockingEffectsCount:', blockingEffectsCount,
'\n[serverRenderLoop] blockingEffectsCountInitial:', blockingEffectsCountInitial,
'\n'
);
if ( rendersRemaining <= 0 || ( !hasNewActions && !hasBlockingEffects ) ) {
debugLog(
'[serverRenderLoop] terminating by state...',
'\n[serverRenderLoop] rendersRemaining:', rendersRemaining,
'\n[serverRenderLoop] hasNewActions:', hasNewActions,
'\n[serverRenderLoop] hasBlockingEffects:', hasBlockingEffects,
'\n'
);
terminate();
return;
}
else if ( hasBlockingEffects ) {
// Wait for more actions after blocking effects resolve.
// We expect that the sagas unconditionally dispatch an action after every asynchronous effect.
return;
}
// Render once again to handle the remaining actions.
renderLoop();
});
};
// Ensure the render terminates within certain time.
if ( timeout > 0 ) {
debugLog('[serverRenderLoop] timeout configured: ' + timeout);
serverRenderLoopContext.timeoutTimer = setTimeout(() => {
debugLog('[serverRenderLoop] terminating by timeout...');
terminate();
}, timeout);
}
debugLog('[serverRenderLoop] subscribing to actionMonitor...');
serverRenderLoopContext.unsubscribe = store.actionMonitor.subscribe(renderLoop);
renderLoop();
});
}
export default runRenderLoop;
// ===== in createStore.js
import createSagaMiddleware, { END as REDUX_SAGA_END } from 'redux-saga';
import createActionMonitor from './actionMonitor';
import createSagaMonitor from './sagaMonitor';
export default function createStore( /* ... */ ) {
const middleware = [];
// ...
// `actionMonitor` and `endSaga` required for the `serverRenderLoop` on __SERVER__.
let actionMonitor;
if ( __SERVER__ ) {
actionMonitor = createActionMonitor({
debug: ( __DEVELOPMENT__ && false ),
});
middleware.push(actionMonitor.middleware());
}
// `sagaMonitor` is required for the `serverRenderLoop` on __SERVER__.
// We keep `sagaMonitor` on the client for debugging.
let sagaMonitor;
if ( __DEVELOPMENT__ || __SERVER__ ) {
sagaMonitor = createSagaMonitor({
debug: ( __DEVELOPMENT__ && false ),
});
}
const sagaMiddleware = createSagaMiddleware({
sagaMonitor: sagaMonitor,
});
middleware.push(sagaMiddleware);
// ...
store.runSaga = sagaMiddleware.run;
// `sagaMonitor` is required for the `serverRenderLoop` on __SERVER__.
// We keep `sagaMonitor` on the client for debugging.
if ( __DEVELOPMENT__ || __SERVER__ ) {
store.sagaMonitor = sagaMonitor;
}
// `actionMonitor` and `endSaga` required for the `serverRenderLoop` on __SERVER__.
if ( __SERVER__ ) {
store.endSaga = () => store.dispatch(REDUX_SAGA_END);
store.actionMonitor = actionMonitor;
}
// ...
return store;
}
// ===== in server.js
// Serve Redux universal app.
app.use((req, res) => {
// ...
const store = createStore( /* ... */ );
const sagaPromise = store.runSaga(rootSaga, /* ... */ ).done;
// ...
const renderDOM = (
<ReduxStoreProvider store={store} key="provider">
{ /* ... */ }
</ReduxStoreProvider>
);
return runRenderLoop({
store,
renderDOM,
sagaPromise,
}).then((renderedString) => {
// ...
const responseHtml = (
'<!doctype html>' +
'<html>' +
// ...
'<div id="react-root">' + renderedString + '</div>' +
// ...
'</html>'
);
// ...
res.set('Content-Type', 'text/html');
res.end( responseHtml );
});
// ...
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment