Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: wire up ssr-server POC for handling SSR requests
- Loading branch information
Showing
6 changed files
with
407 additions
and
0 deletions.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
<?php | ||
|
||
namespace Bolt\SSR; | ||
|
||
// use \Drupal\Core\Template\Attribute; | ||
use Bolt; | ||
use Bolt\BoltStringLoader; | ||
|
||
|
||
// Default attributes and inheritted data all grid components inherit (ex. base CSS class) | ||
// $GLOBALS['grid_attributes'] = array('class' => array('o-bolt-grid')); | ||
// Crude way to track which instance of the component is being referenced so each component's unique data is encapsulated and merged together properly without bleeding over. | ||
// $GLOBALS['counter'] = 0; | ||
// Expose the D8 and Pattern Lab "create_attribute" function in case this custom Twig Tag gets loaded before the create_attribute Twig extension exists. | ||
|
||
class SSRTagNode extends \Twig_Node { | ||
|
||
public function __construct($params, $lineno = 0, $tag = null){ | ||
parent::__construct(array ('params' => $params), array (), $lineno, $tag); | ||
} | ||
|
||
// // Loop through any non-string data paramaters passed into the component | ||
// static public function displayRecursiveResults($arrayObject) { | ||
// foreach($arrayObject as $key=>$value) { | ||
// // Handle attributes objects a little differently so we can merge this directly with the inheritted attrs | ||
// if(is_array($value) && $key == 'attributes') { | ||
// $GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ] = array_merge_recursive($GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ], $value); | ||
// } elseif (is_array($value)) { | ||
// self::displayRecursiveResults($value); | ||
// } elseif(is_object($value)) { | ||
// self::displayRecursiveResults($value); | ||
// } else { | ||
// $GLOBALS['grid_props'][ $GLOBALS['counter'] ] = array_merge_recursive($GLOBALS['grid_props'][ $GLOBALS['counter'] ], array($key => $value)); | ||
// } | ||
// } | ||
// } | ||
|
||
|
||
static public function functionToCall(){ | ||
$params = func_get_args(); | ||
$contents = array_shift($params); | ||
// If paramaters exist for this particular component instance, merge everything together | ||
// if ($params){ | ||
// // $new_grid_attributes = array("class" => array("")); // Store inline strings | ||
// // $GLOBALS['counter'] = $GLOBALS['counter'] + 1; //Track the current grid instance | ||
// // $GLOBALS['grid_props'][ $GLOBALS['counter'] ] = array(); //Store misc data that isn't attributes | ||
// // $GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ] = array(); // Store custom attributes passed in | ||
|
||
|
||
// // Handle legacy way of handling data via a simple string on the component (ex. {% grid 'u-1/1' %} ) | ||
// // foreach ($params as $key => $value){ | ||
// // if (gettype($value) == 'string') { | ||
// // // $classes = explode(" ", $value); | ||
// // // $new_grid_attributes = array_merge_recursive(array("class" => $classes), $new_grid_attributes); | ||
// // } elseif (gettype($value) == 'array') { | ||
// // self::displayRecursiveResults($value); | ||
// // } else { | ||
// // // Catch all for everything else. | ||
// // } | ||
// // } | ||
// } | ||
// Do any custom attributes already exist? If not, set an empty array so it can get merged (below) | ||
// if (!isset($GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ])) { | ||
// $GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ] = array(); | ||
// } | ||
// After capturing and merging in all string + array parameters on the component instance, take the unique set of data and merge it with the defaults | ||
// $GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ] = array_merge_recursive($GLOBALS['grid_attributes'], $GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ]); | ||
// Handle instances where no vanilla string parameters (not in an array) are directly added to a component | ||
// if (isset($new_grid_attributes)){ | ||
// $merged_attributes = array_merge_recursive($GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ], $new_grid_attributes); | ||
// } elseif (isset($GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ])) { | ||
// $merged_attributes = $GLOBALS['grid_attributes_custom'][ $GLOBALS['counter'] ]; | ||
// } else { | ||
// $merged_attributes = array(); | ||
// } | ||
// Finally, handle instances where literally zero data or props got passed into the component | ||
// if (!isset($GLOBALS['grid_props'][ $GLOBALS['counter'] ])){ | ||
// $GLOBALS['grid_props'][ $GLOBALS['counter'] ] = array(); | ||
// } | ||
// // Run the captured attributes through D8's createAttribute function, prior to rendering | ||
// $attributes = new Attribute($merged_attributes); | ||
|
||
$stringLoader = new BoltStringLoader(); | ||
|
||
//Setup data into 2 groups: attributes + everything else that we're going to namespace under the component name. | ||
// $data = array( | ||
// "attributes" => $attributes, | ||
// "grid" => $GLOBALS['grid_props'][ $GLOBALS['counter'] ] | ||
// ); | ||
$twig_to_html = $stringLoader->render(array("string" => $contents, "data" => [])); | ||
$rendered_html = \Bolt\TwigFunctions::bolt_ssr($twig_to_html); | ||
|
||
//@TODO: pull in template logic used here from external Twig file. | ||
// $string = "{{ '" . htmlspecialchars($rendered_html) . "' }}"; | ||
// Pre-render the inline Twig template + the data we've merged and normalized | ||
// $rendered = $stringLoader->render(array("string" => $string, "data" => [])); | ||
echo $rendered_html, PHP_EOL; | ||
} | ||
|
||
public function compile(\Twig_Compiler $compiler) { | ||
$count = count($this->getNode('params')); | ||
$compiler->addDebugInfo($this); | ||
|
||
for ($i = 0; ($i < $count); $i++){ | ||
// argument is not an expression (such as, a \Twig_Node_Textbody) | ||
// we should trick with output buffering to get a valid argument to pass | ||
// to the functionToCall() function. | ||
if (!($this->getNode('params')->getNode($i) instanceof \Twig_Node_Expression)){ | ||
$compiler->write('ob_start();')->raw(PHP_EOL); | ||
$compiler->subcompile($this->getNode('params')->getNode($i)); | ||
$compiler->write('$_mytag[] = ob_get_clean();')->raw(PHP_EOL); | ||
} else { | ||
$compiler | ||
->write('$_mytag[] = ') | ||
->subcompile($this->getNode('params')->getNode($i)) | ||
->raw(';') | ||
->raw(PHP_EOL); | ||
} | ||
} | ||
|
||
$compiler | ||
->write('call_user_func_array(') | ||
->string(__NAMESPACE__ . '\SSRTagNode::functionToCall') | ||
->raw(', $_mytag);') | ||
->raw(PHP_EOL); | ||
|
||
$compiler->write('unset($_mytag);')->raw(PHP_EOL); | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
<?php | ||
|
||
namespace Bolt\SSR; | ||
|
||
use Bolt\SSR\SSRTagNode; | ||
|
||
/** | ||
* @author Salem Ghoweri | ||
*/ | ||
|
||
class SSRTagTokenParser extends \Twig_TokenParser { | ||
|
||
public function parse(\Twig_Token $token) { | ||
|
||
$lineno = $token->getLine(); | ||
$stream = $this->parser->getStream(); | ||
$continue = true; | ||
|
||
|
||
$inheritanceIndex = 1; | ||
|
||
$stream = $this->parser->getStream(); | ||
|
||
$nodes = array(); | ||
$classes = array(); | ||
$returnNode = null; | ||
|
||
|
||
// recovers all inline parameters close to your tag name | ||
$params = array_merge(array (), $this->getInlineParams($token)); | ||
|
||
while ($continue) { | ||
// create subtree until the decideMyTagFork() callback returns true | ||
$body = $this->parser->subparse(array ($this, 'decideMyTagFork')); | ||
|
||
// I like to put a switch here, in case you need to add middle tags, such | ||
// as: {% mytag %}, {% nextmytag %}, {% endmytag %}. | ||
$tag = $stream->next()->getValue(); | ||
|
||
switch ($tag){ | ||
case 'endssr': | ||
$continue = false; | ||
break; | ||
default: | ||
throw new \Twig_Error_Syntax(sprintf('Unexpected end of template. Twig was looking for the following tags "endssr" to close the "ssr" block started at line %d)', $lineno), -1); | ||
} | ||
|
||
// you want $body at the beginning of your arguments | ||
array_unshift($params, $body); | ||
|
||
// if your endmytag can also contains params, you can uncomment this line: | ||
// $params = array_merge($params, $this->getInlineParams($token)); | ||
// and comment this one: | ||
$stream->expect(\Twig_Token::BLOCK_END_TYPE); | ||
} | ||
|
||
return new SSRTagNode(new \Twig_Node($params), $lineno, $this->getTag()); | ||
} | ||
|
||
/** | ||
* Recovers all tag parameters until we find a BLOCK_END_TYPE ( %} ) | ||
* | ||
* @param \Twig_Token $token | ||
* @return array | ||
*/ | ||
public function getInlineParams(\Twig_Token $token) { | ||
$stream = $this->parser->getStream(); | ||
$params = array (); | ||
while (!$stream->test(\Twig_Token::BLOCK_END_TYPE)) { | ||
$params[] = $this->parser->getExpressionParser()->parseExpression(); | ||
} | ||
$stream->expect(\Twig_Token::BLOCK_END_TYPE); | ||
return $params; | ||
} | ||
|
||
|
||
public function getTag() { | ||
return "ssr"; | ||
} | ||
|
||
/** | ||
* Callback called at each tag name when subparsing, must return | ||
* true when the expected end tag is reached. | ||
* | ||
* @param \Twig_Token $token | ||
* @return bool | ||
*/ | ||
public function decideMyTagFork(\Twig_Token $token) { | ||
return $token->test(array("ssr", "endssr")); | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
// Helper util to check if being client-side vs server-side rendered | ||
|
||
export const buildMode = bolt.buildMode; | ||
export const isServer = bolt.isServer; | ||
export const isClient = bolt.isClient; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import path from 'path'; | ||
import express from 'express'; | ||
import webpack from 'webpack'; | ||
import createWebpackConfig from '@bolt/build-tools/create-webpack-config'; | ||
import * as configStore from '@bolt/build-tools/utils/config-store.js'; | ||
import { renderPage } from './ssr-server.puppeteer'; | ||
import { template } from './ssr-server.template'; | ||
const getConfig = configStore.default.getConfig; | ||
|
||
const htmlToRender = process.argv[2] || ''; | ||
const port = process.env.PORT || 4444; | ||
const app = express(); | ||
let connections = []; // keep track of # of open connections | ||
|
||
let server; // express server instance | ||
|
||
getConfig().then(async boltConfig => { | ||
let config = boltConfig; | ||
|
||
config.components.individual = []; | ||
// config.components.global = ['@bolt/components-button']; | ||
config.prod = true; | ||
config.enableCache = true; | ||
config.mode = 'server'; | ||
config.sourceMaps = false; | ||
|
||
// config.components.global = config.components.global.filter( | ||
// item => | ||
// !item.includes('bolt-icons') && | ||
// !item.includes('bolt-critical') && | ||
// !item.includes('packages/core/index.js'), | ||
// ); | ||
|
||
const webpackConfig = await createWebpackConfig(config); | ||
|
||
webpackConfig[0].entry['bolt-global'] = webpackConfig[0].entry[ | ||
'bolt-global' | ||
].filter( | ||
item => !item.includes('.scss'), | ||
// !item.includes('bolt-critical') && | ||
// !item.includes('bolt-icons') && | ||
// !item.includes('bolt-video') && | ||
// !item.includes('packages/core/index.js') && | ||
// !item.includes('packages/global/styles/index.js'), | ||
); | ||
|
||
const staticDir = path.join(process.cwd(), config.wwwDir); | ||
|
||
app.use(express.static(staticDir)); | ||
|
||
// generate a fresh webpack build + pass along asset data to dynamic HTML template rendered | ||
server = await app.listen(port); | ||
await setupServer(); | ||
|
||
// console.log(`Express listening on http://localhost:${port}`); | ||
await webpack(webpackConfig, async (err, webpackStats) => { | ||
// @todo: handle webpack errors | ||
// if (err || webpackStats.hasErrors()) {} | ||
const webpackStatsGenerated = webpackStats.toJson().children[0] | ||
.assetsByChunkName; | ||
|
||
app.get('/ssr', function(req, res) { | ||
res.send(template.render(htmlToRender, webpackStatsGenerated, config)); | ||
}); | ||
|
||
await renderPage(port); | ||
}); | ||
}); | ||
|
||
async function setupServer() { | ||
// handle cleaning up + shutting down the server instance | ||
process.on('SIGTERM', shutDownSSRServer); | ||
process.on('SIGINT', shutDownSSRServer); | ||
|
||
server.on('connection', connection => { | ||
connections.push(connection); | ||
connection.on( | ||
'close', | ||
// eslint-disable-next-line no-return-assign | ||
() => (connections = connections.filter(curr => curr !== connection)), | ||
); | ||
}); | ||
} | ||
|
||
export function shutDownSSRServer() { | ||
// console.log('Received kill signal, shutting down gracefully'); | ||
server.close(() => { | ||
// console.log('Closed out remaining connections'); | ||
process.exit(0); | ||
}); | ||
|
||
setTimeout(() => { | ||
console.error( | ||
'Could not close connections in time, forcefully shutting down', | ||
); | ||
process.exit(1); | ||
}, 10000); | ||
|
||
connections.forEach(curr => curr.end()); | ||
setTimeout(() => connections.forEach(curr => curr.destroy()), 5000); | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import puppeteer from 'puppeteer'; | ||
import prettier from 'prettier'; | ||
import highlight from 'cli-highlight'; | ||
import { shutDownSSRServer } from './ssr-server'; | ||
|
||
export async function renderPage(port) { | ||
const url = `http://localhost:${port}/ssr`; | ||
|
||
const browser = await puppeteer.launch({ | ||
headless: true, | ||
}); | ||
|
||
const page = await browser.newPage(); | ||
await page.goto(url); | ||
await page.content(); // serialized HTML of page DOM. | ||
|
||
const html = await page.evaluate(() => { | ||
// strip out any <script> tags from the SSR-rendered body HTML | ||
function stripScripts(s) { | ||
var div = document.createElement('div'); | ||
div.innerHTML = s; | ||
var scripts = div.getElementsByTagName('script'); | ||
var i = scripts.length; | ||
while (i--) { | ||
scripts[i].parentNode.removeChild(scripts[i]); | ||
} | ||
return div.innerHTML; | ||
} | ||
|
||
const code = document.body.innerHTML; | ||
|
||
// strip out any lit-html comments in the rendered HTML before returning | ||
return stripScripts(code).replace(/<!---->/g, ''); | ||
}); | ||
|
||
const renderedHTML = prettier.format(html, { | ||
singleQuote: true, | ||
trailingComma: 'es5', | ||
bracketSpacing: true, | ||
jsxBracketSameLine: true, | ||
parser: 'html', | ||
}); | ||
|
||
console.log( | ||
highlight.highlight(renderedHTML, { | ||
language: 'html', | ||
ignoreIllegals: true, | ||
}), | ||
); | ||
|
||
await browser.close(); | ||
await shutDownSSRServer(); | ||
} |
Oops, something went wrong.