Server render the main page

Also, add hashes to asset file names and use the "entry manifest" plugin
in dev to get auto-reloading.
This commit is contained in:
Michael Jackson
2019-01-12 19:27:28 -08:00
parent 45c48cba26
commit 09914c1db4
176 changed files with 2725 additions and 119822 deletions

View File

@ -2,31 +2,18 @@ import React from 'react';
import ReactDOMServer from 'react-dom/server';
import semver from 'semver';
import MainPage from '../client/MainPage';
import MainTemplate from '../client/MainTemplate';
import AutoIndexApp from '../client/autoIndex/App';
import createHTML from '../client/utils/createHTML';
import renderPage from '../utils/renderPage';
const globalScripts =
process.env.NODE_ENV === 'production'
? [
'/react@16.7.0/umd/react.production.min.js',
'/react-dom@16.7.0/umd/react-dom.production.min.js'
]
: [
'/react@16.7.0/umd/react.development.js',
'/react-dom@16.7.0/umd/react-dom.development.js'
];
import getEntryPoints from '../utils/getEntryPoints';
import renderTemplate from '../utils/renderTemplate';
function byVersion(a, b) {
return semver.lt(a, b) ? -1 : semver.gt(a, b) ? 1 : 0;
}
export default function serveAutoIndexPage(req, res) {
const scripts = globalScripts.concat('/_assets/autoIndex.js');
const styles = ['/autoIndex.css'];
const props = {
const data = {
packageName: req.packageName,
packageVersion: req.packageVersion,
availableVersions: Object.keys(req.packageInfo.versions).sort(byVersion),
@ -34,17 +21,22 @@ export default function serveAutoIndexPage(req, res) {
entry: req.entry,
entries: req.entries
};
const content = createHTML(
ReactDOMServer.renderToString(React.createElement(AutoIndexApp, props))
ReactDOMServer.renderToString(React.createElement(AutoIndexApp, data))
);
const html = renderPage(MainPage, {
const entryPoints = getEntryPoints('autoIndex', {
es: 'module',
system: 'nomodule'
});
const html = renderTemplate(MainTemplate, {
title: `UNPKG - ${req.packageName}`,
description: `The CDN for ${req.packageName}`,
scripts: scripts,
styles: styles,
data: props,
content: content
data,
content,
entryPoints
});
res

View File

@ -0,0 +1,32 @@
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import MainTemplate from '../client/MainTemplate';
import MainApp from '../client/main/App';
import createHTML from '../client/utils/createHTML';
import getEntryPoints from '../utils/getEntryPoints';
import renderTemplate from '../utils/renderTemplate';
export default function serveMainPage(req, res) {
const element = React.createElement(
StaticRouter,
{ location: req.url },
React.createElement(MainApp)
);
const content = createHTML(ReactDOMServer.renderToString(element));
const entryPoints = getEntryPoints('main', {
es: 'module',
system: 'nomodule'
});
const html = renderTemplate(MainTemplate, { content, entryPoints });
res
.set({
'Cache-Control': 'public, max-age=14400', // 4 hours
'Cache-Tag': 'main'
})
.send(html);
}

View File

@ -1,4 +1,9 @@
{
"presets": [["@babel/env", { "loose": true }], "@babel/react"],
"plugins": [["@babel/plugin-proposal-class-properties", { "loose": true }]]
"presets": [
["@babel/env", { "loose": true }],
"@babel/react"
],
"plugins": [
["@babel/plugin-proposal-class-properties", { "loose": true }]
]
}

View File

@ -1,72 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import createHTML from './utils/createHTML';
import x from './utils/execScript';
export default function MainPage({
title,
description,
favicon,
scripts,
styles,
data,
content
}) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge,chrome=1" />
{description && <meta name="description" content={description} />}
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1"
/>
<meta name="timestamp" content={new Date().toISOString()} />
{favicon && <link rel="shortcut icon" href={favicon} />}
{styles.map(s => (
<link key={s} rel="stylesheet" href={s} />
))}
{x(
'window.Promise || document.write(\'\\x3Cscript src="/_polyfills/es6-promise.min.js">\\x3C/script>\\x3Cscript>ES6Promise.polyfill()\\x3C/script>\')'
)}
{x(
'window.fetch || document.write(\'\\x3Cscript src="/_polyfills/fetch.min.js">\\x3C/script>\')'
)}
{x(`window.__DATA__ = ${JSON.stringify(data)}`)}
<title>{title}</title>
</head>
<body>
<div id="root" dangerouslySetInnerHTML={content} />
{scripts.map(s => (
<script key={s} src={s} />
))}
</body>
</html>
);
}
MainPage.defaultProps = {
title: 'UNPKG',
description: 'The CDN for everything on npm',
favicon: '/favicon.ico',
scripts: [],
styles: [],
data: {},
content: createHTML('')
};
const htmlType = PropTypes.shape({
__html: PropTypes.string
});
MainPage.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
favicon: PropTypes.string,
scripts: PropTypes.arrayOf(PropTypes.string),
styles: PropTypes.arrayOf(PropTypes.string),
data: PropTypes.any,
content: htmlType
};

View File

@ -0,0 +1,89 @@
import React from 'react';
import PropTypes from 'prop-types';
import createHTML from './utils/createHTML';
import x from './utils/execScript';
const promiseShim =
'window.Promise || document.write(\'\\x3Cscript src="/es6-promise@4.2.5/dist/es6-promise.min.js">\\x3C/script>\\x3Cscript>ES6Promise.polyfill()\\x3C/script>\')';
const fetchShim =
'window.fetch || document.write(\'\\x3Cscript src="/whatwg-fetch@3.0.0/dist/fetch.umd.js">\\x3C/script>\')';
export default function MainTemplate({
title,
description,
favicon,
data,
content,
globalScripts,
entryPoints
}) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge,chrome=1" />
{description && <meta name="description" content={description} />}
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1"
/>
<meta name="timestamp" content={new Date().toISOString()} />
{favicon && <link rel="shortcut icon" href={favicon} />}
<title>{title}</title>
{x(promiseShim)}
{x(fetchShim)}
{data && x(`window.__DATA__ = ${JSON.stringify(data)}`)}
</head>
<body>
<div id="root" dangerouslySetInnerHTML={content} />
{globalScripts.map(src => (
<script key={src} src={src} />
))}
{entryPoints.module &&
x(`
import('${entryPoints.module}');
window.supportsDynamicImport = true;
`)}
{entryPoints.nomodule &&
x(`
if (!window.supportsDynamicImport) {
var s = document.createElement('script');
s.src = '/systemjs@2.0.0/dist/s.min.js';
s.addEventListener('load', function() {
System.import('${entryPoints.nomodule}');
});
document.head.appendChild(s);
}
`)}
</body>
</html>
);
}
MainTemplate.defaultProps = {
title: 'UNPKG',
description: 'The CDN for everything on npm',
favicon: '/favicon.ico',
content: createHTML(''),
globalScripts: []
};
const htmlType = PropTypes.shape({
__html: PropTypes.string
});
MainTemplate.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
favicon: PropTypes.string,
data: PropTypes.any,
content: htmlType,
globalScripts: PropTypes.arrayOf(PropTypes.string),
entryPoints: PropTypes.shape({
module: PropTypes.string,
nomodule: PropTypes.string
}).isRequired
};

View File

@ -1,8 +0,0 @@
body {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
line-height: 1.7;
padding: 0px 10px 5px;
color: #000000;
}

View File

@ -1,10 +1,26 @@
// import './autoIndex.css';
import React from 'react';
import ReactDOM from 'react-dom';
import { Global, css } from '@emotion/core';
import App from './autoIndex/App';
const globalStyles = css`
body {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 0px 10px 5px;
color: #000000;
}
`;
const props = window.__DATA__ || {};
ReactDOM.hydrate(<App {...props} />, document.getElementById('root'));
ReactDOM.hydrate(
<div>
<Global styles={globalStyles} />
<App {...props} />
</div>,
document.getElementById('root')
);

View File

@ -26,18 +26,7 @@ const styles = {
}
};
const entryType = PropTypes.object;
export default class App extends React.Component {
static propTypes = {
packageName: PropTypes.string.isRequired,
packageVersion: PropTypes.string.isRequired,
availableVersions: PropTypes.arrayOf(PropTypes.string),
filename: PropTypes.string.isRequired,
entry: entryType.isRequired,
entries: PropTypes.objectOf(entryType).isRequired
};
static defaultProps = {
availableVersions: []
};
@ -92,3 +81,16 @@ export default class App extends React.Component {
);
}
}
if (process.env.NODE_ENV !== 'production') {
const entryType = PropTypes.object;
App.propTypes = {
packageName: PropTypes.string.isRequired,
packageVersion: PropTypes.string.isRequired,
availableVersions: PropTypes.arrayOf(PropTypes.string),
filename: PropTypes.string.isRequired,
entry: entryType.isRequired,
entries: PropTypes.objectOf(entryType).isRequired
};
}

View File

@ -126,12 +126,14 @@ export default function DirectoryListing({ filename, entry, entries }) {
);
}
const entryType = PropTypes.shape({
name: PropTypes.string.isRequired
});
if (process.env.NODE_ENV !== 'production') {
const entryType = PropTypes.shape({
name: PropTypes.string.isRequired
});
DirectoryListing.propTypes = {
filename: PropTypes.string.isRequired,
entry: entryType.isRequired,
entries: PropTypes.objectOf(entryType).isRequired
};
DirectoryListing.propTypes = {
filename: PropTypes.string.isRequired,
entry: entryType.isRequired,
entries: PropTypes.objectOf(entryType).isRequired
};
}

View File

@ -1,63 +0,0 @@
body {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
line-height: 1.7;
padding: 5px 20px;
color: #000000;
}
@media (min-width: 800px) {
body {
padding: 40px 20px 120px;
}
}
a:link {
color: blue;
}
a:visited {
color: rebeccapurple;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.6em;
}
ul {
padding-left: 25px;
}
dd {
margin-left: 25px;
}
table {
border: 1px solid black;
border: 0;
}
th {
text-align: left;
background-color: #eee;
}
th,
td {
padding: 5px;
}
th {
vertical-align: bottom;
}
td {
vertical-align: top;
}
.wrapper {
max-width: 700px;
margin: 0 auto;
}

View File

@ -1,8 +1,82 @@
// import './main.css';
import React from 'react';
import ReactDOM from 'react-dom';
import { HashRouter } from 'react-router-dom';
import { Global, css } from '@emotion/core';
import App from './main/App';
ReactDOM.render(<App />, document.getElementById('root'));
const globalStyles = css`
body {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 5px 20px;
color: #000000;
}
@media (min-width: 800px) {
body {
padding: 40px 20px 120px;
}
}
a:link {
color: blue;
}
a:visited {
color: rebeccapurple;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.6em;
}
ul {
padding-left: 25px;
}
dd {
margin-left: 25px;
}
table {
border: 1px solid black;
border: 0;
}
th {
text-align: left;
background-color: #eee;
}
th,
td {
padding: 5px;
}
th {
vertical-align: bottom;
}
td {
vertical-align: top;
}
.wrapper {
max-width: 700px;
margin: 0 auto;
}
`;
ReactDOM.render(
<HashRouter>
<div>
<Global styles={globalStyles} />
<App />
</div>
</HashRouter>,
document.getElementById('root')
);

View File

@ -1,14 +1,175 @@
import React from 'react';
import { HashRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Switch, Route, Link, withRouter } from 'react-router-dom';
import { Motion, spring } from 'react-motion';
import Layout from './Layout';
import WindowSize from './WindowSize';
import About from './About';
import Stats from './Stats';
import Home from './Home';
function App() {
return (
<HashRouter>
<Layout />
</HashRouter>
);
const styles = {
title: {
margin: 0,
textTransform: 'uppercase',
textAlign: 'center',
fontSize: '5em'
},
nav: {
margin: '0 0 3em'
},
navList: {
margin: 0,
padding: 0,
display: 'flex',
justifyContent: 'center'
},
navListItem: {
flexBasis: 'auto',
listStyleType: 'none',
display: 'inline-block',
fontSize: '1.1em',
margin: '0 10px'
},
navLink: {
textDecoration: 'none',
color: 'black'
},
navUnderline: {
height: 4,
backgroundColor: 'black',
position: 'absolute',
left: 0
}
};
class Layout extends React.Component {
state = {
underlineLeft: 0,
underlineWidth: 0,
useSpring: false,
stats: null
};
adjustUnderline = (useSpring = false) => {
let itemIndex;
switch (this.props.location.pathname) {
case '/stats':
itemIndex = 1;
break;
case '/about':
itemIndex = 2;
break;
case '/':
default:
itemIndex = 0;
}
const itemNodes = this.listNode.querySelectorAll('li');
const currentNode = itemNodes[itemIndex];
this.setState({
underlineLeft: currentNode.offsetLeft,
underlineWidth: currentNode.offsetWidth,
useSpring
});
};
componentDidMount() {
this.adjustUnderline();
fetch('/api/stats?period=last-month')
.then(res => res.json())
.then(stats => this.setState({ stats }));
if (window.localStorage) {
const savedStats = window.localStorage.savedStats;
if (savedStats) this.setState({ stats: JSON.parse(savedStats) });
window.onbeforeunload = () => {
localStorage.savedStats = JSON.stringify(this.state.stats);
};
}
}
componentDidUpdate(prevProps) {
if (prevProps.location.pathname !== this.props.location.pathname) {
this.adjustUnderline(true);
}
}
render() {
const { underlineLeft, underlineWidth, useSpring } = this.state;
const style = {
left: useSpring
? spring(underlineLeft, { stiffness: 220 })
: underlineLeft,
width: useSpring ? spring(underlineWidth) : underlineWidth
};
return (
<div className="layout">
<WindowSize onChange={this.adjustUnderline} />
<div className="wrapper">
<header>
<h1 style={styles.title}>unpkg</h1>
<nav style={styles.nav}>
<ol style={styles.navList} ref={node => (this.listNode = node)}>
<li style={styles.navListItem}>
<Link to="/" style={styles.navLink}>
Home
</Link>
</li>
<li style={styles.navListItem}>
<Link to="/stats" style={styles.navLink}>
Stats
</Link>
</li>
<li style={styles.navListItem}>
<Link to="/about" style={styles.navLink}>
About
</Link>
</li>
</ol>
<Motion
defaultStyle={{ left: underlineLeft, width: underlineWidth }}
style={style}
children={style => (
<div
style={{
...styles.navUnderline,
WebkitTransform: `translate3d(${style.left}px,0,0)`,
transform: `translate3d(${style.left}px,0,0)`,
width: style.width
}}
/>
)}
/>
</nav>
</header>
</div>
<Switch>
<Route
path="/stats"
render={() => <Stats data={this.state.stats} />}
/>
<Route path="/about" component={About} />
<Route path="/" component={Home} />
</Switch>
</div>
);
}
}
export default App;
if (process.env.NODE_ENV !== 'production') {
Layout.propTypes = {
location: PropTypes.object,
children: PropTypes.node
};
}
export default withRouter(Layout);

View File

@ -1,172 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Switch, Route, Link, withRouter } from 'react-router-dom';
import { Motion, spring } from 'react-motion';
import WindowSize from './WindowSize';
import About from './About';
import Stats from './Stats';
import Home from './Home';
const styles = {
title: {
margin: 0,
textTransform: 'uppercase',
textAlign: 'center',
fontSize: '5em'
},
nav: {
margin: '0 0 3em'
},
navList: {
margin: 0,
padding: 0,
display: 'flex',
justifyContent: 'center'
},
navListItem: {
flexBasis: 'auto',
listStyleType: 'none',
display: 'inline-block',
fontSize: '1.1em',
margin: '0 10px'
},
navLink: {
textDecoration: 'none',
color: 'black'
},
navUnderline: {
height: 4,
backgroundColor: 'black',
position: 'absolute',
left: 0
}
};
class Layout extends React.Component {
static propTypes = {
location: PropTypes.object,
children: PropTypes.node
};
state = {
underlineLeft: 0,
underlineWidth: 0,
useSpring: false,
stats: null
};
adjustUnderline = (useSpring = false) => {
let itemIndex;
switch (this.props.location.pathname) {
case '/stats':
itemIndex = 1;
break;
case '/about':
itemIndex = 2;
break;
case '/':
default:
itemIndex = 0;
}
const itemNodes = this.listNode.querySelectorAll('li');
const currentNode = itemNodes[itemIndex];
this.setState({
underlineLeft: currentNode.offsetLeft,
underlineWidth: currentNode.offsetWidth,
useSpring
});
};
componentDidMount() {
this.adjustUnderline();
fetch('/api/stats?period=last-month')
.then(res => res.json())
.then(stats => this.setState({ stats }));
if (window.localStorage) {
const savedStats = window.localStorage.savedStats;
if (savedStats) this.setState({ stats: JSON.parse(savedStats) });
window.onbeforeunload = () => {
localStorage.savedStats = JSON.stringify(this.state.stats);
};
}
}
componentDidUpdate(prevProps) {
if (prevProps.location.pathname !== this.props.location.pathname) {
this.adjustUnderline(true);
}
}
render() {
const { underlineLeft, underlineWidth, useSpring } = this.state;
const style = {
left: useSpring
? spring(underlineLeft, { stiffness: 220 })
: underlineLeft,
width: useSpring ? spring(underlineWidth) : underlineWidth
};
return (
<div className="layout">
<WindowSize onChange={this.adjustUnderline} />
<div className="wrapper">
<header>
<h1 style={styles.title}>unpkg</h1>
<nav style={styles.nav}>
<ol style={styles.navList} ref={node => (this.listNode = node)}>
<li style={styles.navListItem}>
<Link to="/" style={styles.navLink}>
Home
</Link>
</li>
<li style={styles.navListItem}>
<Link to="/stats" style={styles.navLink}>
Stats
</Link>
</li>
<li style={styles.navListItem}>
<Link to="/about" style={styles.navLink}>
About
</Link>
</li>
</ol>
<Motion
defaultStyle={{ left: underlineLeft, width: underlineWidth }}
style={style}
children={style => (
<div
style={{
...styles.navUnderline,
WebkitTransform: `translate3d(${style.left}px,0,0)`,
transform: `translate3d(${style.left}px,0,0)`,
width: style.width
}}
/>
)}
/>
</nav>
</header>
</div>
<Switch>
<Route
path="/stats"
render={() => <Stats data={this.state.stats} />}
/>
<Route path="/about" component={About} />
<Route path="/" component={Home} />
</Switch>
</div>
);
}
}
export default withRouter(Layout);

View File

@ -35,10 +35,6 @@ function sumKeyValues(hash, keys) {
// }
export default class Stats extends React.Component {
static propTypes = {
data: PropTypes.object
};
state = {
// minPackageRequests: 1000000,
minCountryRequests: 1000000
@ -333,3 +329,9 @@ export default class Stats extends React.Component {
);
}
}
if (process.env.NODE_ENV !== 'production') {
Stats.propTypes = {
data: PropTypes.object
};
}

View File

@ -6,10 +6,6 @@ import { addEvent, removeEvent } from '../utils/dom';
const resizeEvent = 'resize';
export default class WindowSize extends React.Component {
static propTypes = {
onChange: PropTypes.func
};
handleWindowResize = () => {
if (this.props.onChange) {
this.props.onChange({
@ -31,3 +27,9 @@ export default class WindowSize extends React.Component {
return null;
}
}
if (process.env.NODE_ENV !== 'production') {
WindowSize.propTypes = {
onChange: PropTypes.func
};
}

View File

@ -2,6 +2,7 @@ import { https } from 'firebase-functions';
// import serveAuth from './serveAuth';
import serveAutoIndexPage from './serveAutoIndexPage';
import serveMainPage from './serveMainPage';
import serveNpmPackageFile from './serveNpmPackageFile';
import servePublicKey from './servePublicKey';
import serveStats from './serveStats';
@ -9,6 +10,7 @@ import serveStats from './serveStats';
export default {
// serveAuth: https.onRequest(serveAuth),
serveAutoIndexPage: https.onRequest(serveAutoIndexPage),
serveMainPage: https.onRequest(serveMainPage),
serveNpmPackageFile: https.onRequest(serveNpmPackageFile),
servePublicKey: https.onRequest(servePublicKey),
serveStats: https.onRequest(serveStats)

View File

@ -0,0 +1,10 @@
import express from 'express';
import serveMainPage from '../actions/serveMainPage';
const app = express();
app.disable('x-powered-by');
app.use(serveMainPage);
export default app;

View File

@ -0,0 +1,19 @@
import invariant from 'invariant';
// Virtual module id; see rollup.config.js
import entryManifest from 'entry-manifest';
export default function getEntryPoints(entryName, formatKeys) {
const manifest = entryManifest[entryName];
invariant(manifest, 'Invalid entry name: %s', entryName);
return manifest.reduce((memo, entryPoint) => {
if (entryPoint.format in formatKeys) {
const key = formatKeys[entryPoint.format];
memo[key] = entryPoint.url;
}
return memo;
}, {});
}

View File

@ -3,9 +3,9 @@ import ReactDOMServer from 'react-dom/server';
const doctype = '<!DOCTYPE html>';
export default function renderPage(page, props) {
export default function renderTemplate(component, props) {
return (
doctype +
ReactDOMServer.renderToStaticMarkup(React.createElement(page, props))
ReactDOMServer.renderToStaticMarkup(React.createElement(component, props))
);
}