Skip to content

Commit

Permalink
feat: wire up ssr-server POC for handling SSR requests
Browse files Browse the repository at this point in the history
  • Loading branch information
sghoweri committed Feb 28, 2019
1 parent 752c0df commit 5c24e5a
Show file tree
Hide file tree
Showing 6 changed files with 407 additions and 0 deletions.
129 changes: 129 additions & 0 deletions packages/core-php/src/SSR/SSRTagNode.php
@@ -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);
}
}
91 changes: 91 additions & 0 deletions packages/core-php/src/SSR/SSRTagTokenParser.php
@@ -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"));
}
}
5 changes: 5 additions & 0 deletions packages/core/utils/mode.js
@@ -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;
101 changes: 101 additions & 0 deletions server/ssr-server.mjs
@@ -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);
}
53 changes: 53 additions & 0 deletions server/ssr-server.puppeteer.mjs
@@ -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();
}

0 comments on commit 5c24e5a

Please sign in to comment.