Repository: davideast/hnpwa-firebase Branch: master Commit: 810a3ba97781 Files: 24 Total size: 26.9 KB Directory structure: gitextract_b36yke40/ ├── .firebaserc ├── .gitignore ├── README.md ├── firebase.json ├── package.json └── src/ ├── build/ │ ├── build.ts │ ├── css/ │ │ ├── base.css │ │ ├── item.css │ │ └── stories.css │ ├── deploy_staging.sh │ ├── index.html │ ├── offline.server.ts │ ├── sw.main.js │ ├── tsconfig.json │ └── workbox.types.ts ├── server/ │ ├── embedcss.ts │ ├── index.ts │ ├── package.json │ ├── templates.ts │ ├── tsconfig.json │ └── utils.ts └── static/ ├── 404.html ├── manifest.json └── sw.reg.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .firebaserc ================================================ { "projects": { "default": "hnpwa-firebase", "staging": "hnpwa-firebase-staging" } } ================================================ FILE: .gitignore ================================================ node_modules .DS_STORE npm-debug.log firebase-debug.log dist ================================================ FILE: README.md ================================================

HNPWA Firebase

A Dynamic, CDN Cached, HNPWA implementation on Firebase Dynamic Hosting.

Demo

## Highlights - **Emerging Markets** - 1.3 - **3G** - 0.7 - **CDN Cached** - Every file on Firebase Hosting is served through a CDN - **Dynamic** - Cloud Functions + Firebase Hosting = Dynamic CDN population ## Contribute!? ```bash git clone https://github.com/davideast/hnpwa-firebase.git npm i npm run build # Basic serving npm run serve # Firebase Hosting Emulation node_modules/.bin/firebase login # Use your own project node_modules/.bin/firebase use -add npm run serve:firebase # Offline serving A.K.A - Deving on an Airplane/bus/elevator npm run save:offline # save current HNAPI data set offline # go offline npm run serve:offline:api # open new terminal or tmux or something npm run serve:offline ``` ================================================ FILE: firebase.json ================================================ { "functions": { "source": "dist/server" }, "hosting": { "public": "dist/static", "rewrites": [ { "source": "/@(news|newest|show|ask|jobs|item)", "function": "server" }, { "source": "/", "function": "server" }, { "source": "/item/*", "function": "server" } ], "headers": [ { "source": "/sw.main.js", "headers": [ { "key": "Cache-Control", "value": "no-cache" } ] }, { "source": "/images/firebase-logo-64.png", "headers": [ { "key": "Cache-Control", "value": "max-age=31536000" } ] } ] } } ================================================ FILE: package.json ================================================ { "name": "hnpwa-firebase-hosting", "version": "1.0.0", "description": "", "main": "index.js", "dependencies": { "@types/handlebars": "^4.0.33", "@types/request-promise": "^4.1.36", "csso": "^3.1.1", "firebase": "^4.1.4", "firebase-admin": "^4.2.1", "firebase-functions": "^0.5.9", "fs-extra": "^4.0.0", "handlebars": "^4.0.10", "html-minifier": "^3.5.2", "ncp": "^2.0.0", "request-promise": "^4.2.1", "typescript": "^2.4.1", "uglify-js": "^3.0.24", "workbox-build": "^1.0.1" }, "devDependencies": { "@types/fs-extra": "^3.0.3", "@types/html-minifier": "^1.1.30", "@types/ncp": "^2.0.0", "@types/node": "^8.0.9", "@types/uglify-js": "^2.6.29", "firebase-tools": "^3.9.1", "hnpwa-api": "^0.1.5", "lighthouse": "^2.1.0", "lighthouse-ci": "https://github.com/ebidel/lighthouse-ci" }, "scripts": { "test": "echo \"lol tests\" && exit 0", "tsc": "node_modules/.bin/tsc -p src/server/tsconfig.json && node_modules/.bin/tsc -p src/build/tsconfig.json", "tsc:watch": "node_modules/.bin/tsc -p src/server/tsconfig.json -w", "build:clean": "rm -rf dist", "build": "npm run build:clean && npm run tsc && node dist/build/build.js", "serve": "npm run tsc && API_BASE=https://hnpwa.com/api/v0 node dist/build/offline.server", "serve:firebase": "npm run tsc && firebase serve --only functions,hosting", "save:offline": "node_modules/.bin/hnpwa-api --save", "serve:offline:api": "node_modules/.bin/hnpwa-api --serve --offline", "serve:offline": "npm run tsc && API_BASE=http://localhost:3002 node dist/build/offline.server", "fns:deps": "cd dist/server && npm i", "deploy": "cd dist/server && npm i && firebase deploy --token \"$FIREBASE_TOKEN\" --project hnpwa-firebase", "deploy:staging": "cd dist/server && npm i && firebase deploy --token \"$FIREBASE_TOKEN\" --project hnpwa-firebase-staging" }, "keywords": [], "author": "", "license": "ISC" } ================================================ FILE: src/build/build.ts ================================================ import { WorkboxBuild, Manifest } from './workbox.types'; import * as embed from '../server/embedcss'; import * as uglify from 'uglify-js'; import * as htmlmin from 'html-minifier'; import * as fs from 'fs-extra'; import * as ncpi from 'ncp'; const workbox: WorkboxBuild = require('workbox-build'); const PRECACHE_MATCHER = '[/** ::MANIFEST:: **/]'; const minify = htmlmin.minify; const ncp = ncpi.ncp; function copyDir(source: string, destination: string) { return new Promise((resolve, reject) => { ncp(source, destination, function ncpCopy(err) { if (err) { reject(err); } resolve(); }); }); } /** * Copy static assets into the directory to deploy to Firebase Hosting */ async function copyStatic() { const staticDir = process.cwd() + '/src/static'; const distDir = process.cwd() + '/dist/static'; await copyDir(staticDir, distDir) console.log(`Copying ${staticDir} to ${distDir}`); } async function copyServer() { const cwd = process.cwd(); return [{ data: fs.readFileSync(`${cwd}/src/server/package.json`, 'utf8'), path: process.cwd() + '/dist/server/package.json', }, { data: fs.readFileSync(`${cwd}/src/server/package-lock.json`, 'utf8'), path: process.cwd() + '/dist/server/package-lock.json', }]; } /** * Copy workbox from npm */ async function copyWorkbox() { const cwd = process.cwd(); const pkgPath = `${cwd}/node_modules/workbox-sw/package.json`; const pkg = require(pkgPath); const readPath = `${cwd}/node_modules/workbox-sw/${pkg.main}`; const data = fs.readFileSync(readPath, 'utf8'); const path = `${cwd}/dist/static/workbox-sw.prod.js`; return [{ data, path }]; } /** * Generate precache entries for the ServiceWorker */ function generateEntries() { return workbox.getFileManifestEntries({ globDirectory: './src/static', globPatterns: ['**\/*.{html,js,css,png,jpg,json}'], globIgnores: ['sw.main.js','404.html', 'images/icons/**/*', 'index.html'], }); } /** * Generate top level Service Worker given precache entries */ async function createSW(entries: Manifest[]) { const swTemplate = fs.readFileSync(process.cwd() + '/src/build/sw.main.js', 'utf8'); const data = swTemplate.replace(PRECACHE_MATCHER, JSON.stringify(entries)); const path = process.cwd() + '/dist/static/sw.main.js'; return [{ data, path }]; } /** * Minify the SW registration code */ async function createMinifiedSWRegistration() { const swregTemplate = fs.readFileSync(process.cwd() + '/src/static/sw.reg.js', 'utf8'); const data = uglify.minify(swregTemplate).code; const path = process.cwd() + '/dist/static/sw.reg.js'; return [{ data, path }]; } /** * Compress the index.html template */ async function createCompressedIndex() { const indexFile = fs.readFileSync(process.cwd() + '/src/build/index.html', 'utf8'); const data = minify(indexFile, { minifyJS: true, collapseWhitespace: true, removeAttributeQuotes: true }); const path = process.cwd() + '/dist/server/index.html'; return [{data, path }]; } /** * Create the style tags for the "story" and "item" based pages. This styles * are generated statically once, and then dynamically plugged when a request * hits. */ async function generateStyles() { const storyCss = await embed.combineCss([ process.cwd() + '/src/build/css/base.css', process.cwd() + '/src/build/css/stories.css' ]); const itemCss = await embed.combineCss([ process.cwd() + '/src/build/css/base.css', process.cwd() + '/src/build/css/item.css' ]); const storiesStyleTag = embed.style(storyCss); const itemStyleTag = embed.style(itemCss); return [ { path: process.cwd() + '/dist/server/stories.css.html', data: storiesStyleTag }, { path: process.cwd() + '/dist/server/item.css.html', data: itemStyleTag }, ]; } /** * Build Steps * (assume tsc has ran) * - Copy assets from npm * - Generate SW entries * - Generate SW from entries * - Minify SW * - Minify SW registration * - Minify index.html * - Generate CSS HTML tags, write to server/css */ async function build() { await copyStatic(); const entries = await generateEntries(); const sw = await createSW(entries); const reg = await createMinifiedSWRegistration(); const index = await createCompressedIndex(); const css = await generateStyles(); const workbox = await copyWorkbox(); const server = await copyServer(); const all = sw.concat(reg, index, css, workbox, server); return all.map(file => { console.log(`Writing ${file.path}.`); return fs.writeFileSync(file.path, file.data, 'utf8'); }); } try { build(); } catch(e) { console.log(e); } ================================================ FILE: src/build/css/base.css ================================================ /* Base */ * { box-sizing: border-box; } p,h1,h2,h3,h4,h5 { padding: 0; margin: 0; } a, a:visited, a:hover { color: #0288d1; } body { border-top: 16px solid #ffa100; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; font-size: calc(15px + 7 * ((100vw - 500px) / 900)); margin: 0; padding: 0; } ul { list-style-type: none; margin: 0; padding: 0; } /* Layout */ .hn-lc { margin: 0 auto; min-width: 320px; padding: 0 2rem; } /* Header module */ .hn-nb { display: flex; justify-content: space-between; padding: 20px 0px; } .hn-hi { padding-right: 30px; } .hn-nl { display: flex; justify-content: space-between; align-items: center; width: 100%; } .hn-nl li a { color: rgba(0, 0, 0, 0.63); font-size: 1.2rem; text-decoration: none; } .hn-nl li a:hover { text-decoration: underline; } @media(max-width: 380px) { .hn-nl li a { font-size: 1rem; } } @media(min-width: 1000px) { .hn-lc { padding: 0 6rem; } } ================================================ FILE: src/build/css/item.css ================================================ .hn-i h2 { padding: 1rem 0; } .hn-fl { font-weight: lighter; } .hn-c { padding: 1rem 0 0; font-size: 1rem; } .hn-cl .hn-cl { margin-left: .5rem } .hn-c p, .hn-ch { margin-bottom: .8rem; word-break: break-word; } .hn-ct { padding-left: .5rem } .hn-c .hn-ch::before { content: '[-] '; cursor: pointer; } .hn-c .hidden.hn-ch::before { content: '[+] '; } .hn-c .hidden.hn-ch ~ .hn-cb { display:none; } ================================================ FILE: src/build/css/stories.css ================================================ .hn-sl > section { border-bottom: 1px solid rgba(239, 239, 239, .8); display: flex; padding: 1rem 0; } .hn-sr { padding: 15px; text-align: right; width: 85px; } .hn-sr h2 { color: rgba(0, 0, 0, 0.63); } .hn-sd { align-items: flex-start; display: flex; flex-direction: column; justify-content: center; width: 100%; } .hn-sd h4 { font-weight: 400; padding: 4px 0; } .hn-sd h4 a { color: rgba(0, 0, 0, 0.87); text-decoration: none; } .hn-sm { font-size: 0.7rem; } .hn-p { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.1rem; position: -webkit-sticky; position: sticky; top: 0; background-color: rgb(255, 255, 255); } ================================================ FILE: src/build/deploy_staging.sh ================================================ node_modules/.bin/firebase deploy --non-interactive --token "$FIREBASE_TOKEN" --project hnpwa-firebase-staging curl https://hnpwa-firebase-staging.firebaseapp.com node node_modules/lighthouse-ci/runlighthouse.js --score=97 https://hnpwa-firebase-staging.firebaseapp.com ================================================ FILE: src/build/index.html ================================================ HNPWA Firebase Node.js Hosting
================================================ FILE: src/build/offline.server.ts ================================================ import * as express from 'express'; import { app as router } from '../server'; const app = express(); app.use(router); app.use(express.static(process.cwd() + '/dist/static')); const PORT = process.env['PORT'] || 3004; const API_BASE = process.env['API_BASE'] || 'http://localhost:3002'; app.listen(PORT, () => console.log(`Listening on ${PORT}. Using API: ${API_BASE}`)); ================================================ FILE: src/build/sw.main.js ================================================ importScripts('/workbox-sw.prod.js'); var w = new self.WorkboxSW(); self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); w.precache([/** ::MANIFEST:: **/]); w.router.registerRoute('/', w.strategies.networkFirst()); w.router.registerRoute(/^\/$|news|newest|show|ask|jobs|item/, w.strategies.networkFirst()); ================================================ FILE: src/build/tsconfig.json ================================================ { "compilerOptions": { "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ "strict": true, /* Enable all strict type-checking options. */ "typeRoots": [ "../../node_modules/@types" ], "lib": [ "es2015" ], "moduleResolution": "node", "outDir": "../../dist" }, "files": [ "build.ts", "offline.server.ts" ] } ================================================ FILE: src/build/workbox.types.ts ================================================ export interface WorkboxBuildOptions { globDirectory: string; globPatterns: string[]; globIgnores?: string[]; swDest?: string; templatedUrls?: { [key: string]: string[] }; mainfestDest?: string; swSrc?: string; } export interface Manifest { revision: string; ur: string; } export interface WorkboxBuild { generateFileManifest(opts: WorkboxBuildOptions): Promise; generateSW(opts: WorkboxBuildOptions): Promise; getFileManifestEntries(opts: WorkboxBuildOptions): Promise; injectManifest(opts: WorkboxBuildOptions): Promise; } ================================================ FILE: src/server/embedcss.ts ================================================ import * as utils from './utils'; const csso: { minify(css: string): { css: string } } = require('csso'); const EMBED_REPLACE_TOKEN = //; export async function combineCss(cssFiles: string[]) { const cssPromises = await cssFiles.map((path: string) => utils.readFile(path)); const cssContent = await Promise.all(cssPromises); const cssCombined = cssContent.join('\n'); const compressedCss = csso.minify(cssCombined).css; return compressedCss; } export async function embedInHtml(htmlFile: string, styleTagFile: string, token = EMBED_REPLACE_TOKEN) { const tag = await utils.readFile(styleTagFile); const html = await utils.readFile(htmlFile); const replaced = html.replace(EMBED_REPLACE_TOKEN, tag); return replaced; } export function style(styles: string) { const tag = ``; const replaced = tag.replace('/** ::STYLES:: **/', styles); return replaced; } ================================================ FILE: src/server/index.ts ================================================ import * as functions from 'firebase-functions'; import * as express from 'express'; import * as fs from 'fs'; import * as request from 'request-promise'; import * as templates from './templates'; import * as Handlebars from 'handlebars'; import * as embedcss from './embedcss'; import * as htmlmin from 'html-minifier'; const minify = htmlmin.minify; export const app = express.Router(); Handlebars.registerPartial('commentList', templates.commentList); const API_BASE = process.env['API_BASE'] || 'https://api.hnpwa.com/v0'; const SECTION_MATCHER = /^\/$|news|newest|show|ask|jobs/; const ITEM_MATCHER = /item\/(\d+$)/; /** * Map the max amount of pages per route, this makes a look up * super easy when rendering the template. */ const MAX_PAGES: { [key: string]: number } = { "news": 10, "jobs": 1, "ask": 3, "show": 2, "/": 10 }; /** * Looks at a string path and returns the matching result. */ function topicLookup(path: string) { if (path === '/') { return 'news'; } return `${path.match(SECTION_MATCHER)![0]}` } /** * Get stories from the API based on the required topic and page number * @param opts */ async function getStories(opts: { path: string, topic: string, page: string}) { const { path, topic, page } = opts; // get story data let storiesJson; // No page lookup is required if it is the root page if(path === '/') { storiesJson = await request(`${API_BASE}/news/1.json`); } else { storiesJson = await request(`${API_BASE}/${topic}/${page}.json`); } return JSON.parse(storiesJson); } function getPagerOptions(topic: string, page: string) { const pageInt = parseInt(page, 10); const back = pageInt - 1; const next = pageInt + 1 const nextPositive = back > 0; const max = MAX_PAGES[topic]; const current = `${page}/${max}`; const maxedOut = pageInt < MAX_PAGES[topic]; return { back, next, nextPositive, max, current, maxedOut }; } async function createStoryPage(topic: string, page: string, stories: any[]) { // compile html from template const template = Handlebars.compile(templates.story); const pagerTemplate = Handlebars.compile(templates.pager); const storyHtml = stories.map((story: any, i: number) => { // handle story rank in template return template({ rank: i + 1, ...story }); }).join(''); // Embed CSS in HTML template const styledIndex = await embedcss.embedInHtml( __dirname + '/index.html', __dirname + '/stories.css.html' ); // Dynamically render the stories in the HTML template const storiesIndex = styledIndex.replace('', storyHtml); const { next, back, nextPositive, current, maxedOut } = getPagerOptions(topic, page); const pageHtml = pagerTemplate({ topic, next, back, nextPositive, current, maxedOut }); return storiesIndex.replace('', pageHtml); } /** * Create an entire section based on it's topic name */ async function renderStories(path: string, page = "1") { const topic = topicLookup(path); const stories = await getStories({ path, topic, page }); const allIndex = await createStoryPage(topic, page, stories); // minify html return minify(allIndex, { minifyJS: true, collapseWhitespace: true, removeAttributeQuotes: true }); } /** * Create a single item based on it's id */ async function renderItem(id: string) { const itemJson = await request(`${API_BASE}/item/${id}.json`) const item = JSON.parse(itemJson); const template = Handlebars.compile(templates.commentTree); const html = template(item); // Embed CSS in HTML template const styledIndex = await embedcss.embedInHtml( __dirname + '/index.html', __dirname + '/item.css.html' ); const itemIndex = styledIndex.replace('', html); return minify(itemIndex, { minifyJS: true, collapseWhitespace: true, removeAttributeQuotes: true }); } /** * Set the Cache-Control header as middleware so we don't have to set it for each and * every route. */ function cacheControl(req: express.Request, res: express.Response, next: Function) { res.set('Cache-Control', 'public; max-age=300, s-maxage=600, stale-while-revalidate=400'); res.set('Link', ';rel=preload;as=script,;rel=preload;as=image'); next(); } app.use(cacheControl); /** * Handle main routes like 'news', 'ask', 'show', 'jobs', etc... */ app.get(SECTION_MATCHER, async (req, res) => { let page = req.query.page; if(!page) { page = "1"; } const storiesHtml = await renderStories(req.path, page); res.send(storiesHtml); }); /** * Handle id query param (/item?id=1) */ app.get('/item', async(req, res) => { let id = req.query.id; const itemHtml = await renderItem(id); res.send(itemHtml); }); /** * Handle clean routes (/item/1) */ app.get(ITEM_MATCHER, async (req, res) => { const id = req.path.replace('/item/', ''); const itemHtml = await renderItem(id); res.send(itemHtml); }); /** * Export express app to Cloud Functions */ export let server = functions.https.onRequest(app as any); ================================================ FILE: src/server/package.json ================================================ { "name": "hnpwa_firebase", "description": "HNPWA app on Firebase node.js hosting", "dependencies": { "@types/handlebars": "^4.0.33", "@types/request-promise": "^4.1.35", "csso": "^3.1.1", "express": "^4.15.3", "firebase-admin": "~4.2.1", "firebase-functions": "^0.5.7", "fs-extra": "^4.0.0", "handlebars": "^4.0.10", "html-minifier": "^3.5.2", "request": "^2.81.0", "request-promise": "^4.2.1" }, "devDependencies": { "workbox-build": "^1.0.1" }, "private": true } ================================================ FILE: src/server/templates.ts ================================================ // This is for syntax highlighting in VSCode const html = String.raw; export const story = html`

{{rank}}

{{title}} {{#if domain}} ({{domain}}) {{/if}}

{{#if points}} {{points}} points {{/if}} {{#if user}} by {{user}} {{/if}} {{time_ago}} | {{comments_count}} comments
`; export const commentTree = html`

{{title}} {{#if domain}}({{domain}}){{/if}}

{{points}} points by {{user}} {{time_ago}} | {{comments_count}} comments

{{{content}}}
{{> commentList comments }}
`; export const commentList = html`
{{#each this}}
{{ user }} {{ time_ago }}
{{{ content }}} {{> commentList comments }}
{{/each}}
`; export const pager = html`
{{#if nextPositive}} back {{else}}
{{/if}}
{{current}}
{{#if maxedOut}} next {{else}}
{{/if}}
`; ================================================ FILE: src/server/tsconfig.json ================================================ { "compilerOptions": { "target": "es2015", "module": "commonjs", "strict": true, "outDir": "../../dist/server" } } ================================================ FILE: src/server/utils.ts ================================================ import * as fs from 'fs'; export function readFile(path: string): Promise { return new Promise((resolve, reject) => { fs.readFile(path, 'utf8', (err: Error, data: any) => { if(err) { reject(err); } resolve(data); }); }); } export function writeFile(path: string, data: string): Promise { return new Promise((resolve, reject) => { fs.writeFile(path, data, (err: Error) => { if(err) { reject(err); return; } resolve(data); }); }); } ================================================ FILE: src/static/404.html ================================================ Page Not Found

404

Page Not Found

The specified file was not found on this website. Please check the URL for mistakes and try again.

Why am I seeing this?

This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html file in your project's configured public directory.

================================================ FILE: src/static/manifest.json ================================================ { "name": "HNPWA", "short_name": "HNPWA", "theme_color": "#ffa100", "background_color": "#ffffff", "display": "standalone", "Scope": "/", "start_url": "/", "icons": [ { "src": "images/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, { "src": "images/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "images/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "images/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" }, { "src": "images/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" }, { "src": "images/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "images/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, { "src": "images/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ], "splash_pages": null } ================================================ FILE: src/static/sw.reg.js ================================================ (function () { if ('serviceWorker' in navigator) { function auf(registration, versionChangeCallback, offlineReadyCallback) { registration.onupdatefound = function () { var installingWorker = registration.installing; installingWorker.onstatechange = function () { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { versionChangeCallback(); } else { offlineReadyCallback(); } } }; }; } navigator.serviceWorker.register('/sw.main.js').then(function (reg) { auf(reg, function versionChanged() { console.log('new version!'); }, function offlineReady() { console.log('offline ready!'); }); }); } }());