Skip to content

Instantly share code, notes, and snippets.

@diestrin
Last active March 6, 2020 22:42
Show Gist options
  • Save diestrin/e7e71030f15e3e91e350a666cd769e1a to your computer and use it in GitHub Desktop.
Save diestrin/e7e71030f15e3e91e350a666cd769e1a to your computer and use it in GitHub Desktop.
import { Observable, of } from 'rxjs';
import { tap, share } from 'rxjs/operators';
import { Injectable, isDevMode } from '@angular/core';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpResponse } from '@angular/common/http';
import { EnvService } from './env.service';
const STATE_KEY = 'state-key';
const StateKey = makeStateKey<Object>(STATE_KEY);
@Injectable({
providedIn: 'root'
})
export class CacheService implements HttpInterceptor {
private cache = new Map<string, any>();
constructor(env: EnvService, private transfer: TransferState) {
if (env.isBrowser) {
this.rehydrate(transfer.get(StateKey, {}));
}
}
public intercept(httpRequest: HttpRequest<any>, handler: HttpHandler) {
// Don't cache if
// 1. It's not a GET request
// 2. If URI is not supposed to be cached
if (httpRequest.method !== 'GET' || !this.has(httpRequest.url)) {
return handler.handle(httpRequest);
}
// Also leave scope of resetting already cached data for a URI
if (httpRequest.headers.get('reset-cache')) {
this.set(httpRequest.urlWithParams, undefined);
}
// Checked if there is cached data for this URI
const lastResponse = this.get(httpRequest.urlWithParams);
if (lastResponse) {
// In case of parallel requests to same URI,
// return the request already in progress
// otherwise return the last cached data
return (lastResponse instanceof Observable)
? lastResponse : of(lastResponse.clone());
}
// If the request of going through for first time
// then let the request proceed and cache the response
const requestHandle = handler.handle(httpRequest)
.pipe(
tap((stateEvent) => {
if (stateEvent instanceof HttpResponse) {
this.set(httpRequest.urlWithParams, stateEvent.clone());
}
}),
share()
);
// Meanwhile cache the request Observable to handle parallel request
this.set(httpRequest.urlWithParams, requestHandle);
return requestHandle;
}
/**
* check if there is a value in our store
*/
public has(key: string|number): boolean {
const _key = this.normalizeKey(key);
return this.cache.has(_key);
}
/**
* store our state
*/
public set(key: string|number, value: any): Map<string, any> {
const _key = this.normalizeKey(key);
this.cache.set(_key, value);
this.transfer.set(StateKey, this.dehydrate());
return this.cache;
}
/**
* get our cached value
*/
public get(key: string|number): any {
const _key = this.normalizeKey(key);
return this.cache.get(_key);
}
/**
* release memory refs
*/
public clear(): void {
this.cache.clear();
this.transfer.set(StateKey, {});
}
/**
* convert to json for the client
*/
public dehydrate(): any {
const json = {};
this.cache.forEach((value: any, key: string) => {
if (value.value instanceof Observable) {
// If there's any value in cache unresolved, don't include it
return;
}
return json[key] = value;
});
return json;
}
/**
* convert server json into out initial state
*/
public rehydrate(json: any): void {
Object.keys(json).forEach((key: string) => {
const _key = this.normalizeKey(key);
const value = json[_key];
this.cache.set(_key, value);
});
}
/**
* allow JSON.stringify to work
*/
public toJSON(): any {
return this.dehydrate();
}
/**
* convert numbers into strings
*/
public normalizeKey(key: string | number): string {
if (isDevMode() && this._isInvalidValue(key)) {
throw new Error('Please provide a valid key to save in the CacheService');
}
return key + '';
}
public _isInvalidValue(key: any): boolean {
return key === null ||
key === undefined ||
key === 0 ||
key === '' ||
typeof key === 'boolean' ||
Number.isNaN(key as number);
}
public inject(): void { }
}
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { Inject, Injectable, Injector, PLATFORM_ID } from '@angular/core';
const envKey = makeStateKey('process.env');
// Variables exposed to front-end, don't expose unnecessary values
const publicEnvVars = [
'MY_ENV_VAR'
];
@Injectable({
providedIn: 'root'
})
export class EnvService {
public isServer = isPlatformServer(this.platformId);
public isBrowser = isPlatformBrowser(this.platformId);
public request = this.isServer ? this.injector.get('request') : {};
public response = this.isServer ? this.injector.get('response') : {};
public hostname = this.request.hostname || '';
public get vars() {
const env = this.injector.get('env');
if (this.isServer) {
return env;
}
return { ...env, ...this.transfer.get(envKey, {}) };
}
public omnitureAccount = this.vars.omnitureAccount || '';
public apiVars = {
apiUrl: this.vars.MY_API_URL,
apiSecret: this.vars.MY_API_SECRET
};
public navigator = this.isBrowser ? navigator : {
userAgent: 'node'
};
public envDependencies = {
jquery: async () => (await this.injector.get('browser:jquery')).default as typeof import('jquery')
};
constructor(
private injector: Injector,
@Inject(PLATFORM_ID)
private platformId: Object,
private transfer: TransferState
) {
if (this.isServer) {
// Expose to browser only certain variables
transfer.set(envKey, publicEnvVars.reduce((vars, key) => ({
...vars,
[key]: this.vars[key]
}), {}));
}
}
}
import { Injectable } from '@angular/core';
import { EnvService } from './env';
/**
* prefix for identify in localStorage
*/
const prefix = 'brand.';
/**
* Save information into the localStorage
*/
@Injectable({
providedIn: 'root'
})
export class LocalStorage {
public localStorage: any;
constructor(private env: EnvService) {
if (env.isBrowser) {
this.localStorage = window.localStorage;
}
}
/**
* Directly adds a value to local storage
*
* @example
* ```
* localStorageService.add('library','angular');
* ```
*/
public add(key: string, value: any, prfx: string = prefix): boolean {
if (this.env.isServer) {
return false;
}
// 0 and "" is allowed as a value but let's limit other falsey values like "undefined"
if (!value && value !== 0 && value !== '') {
return false;
}
const stringifyedValue = typeof value !== 'string' ? JSON.stringify(value) : value;
try {
this.localStorage.setItem(prfx + key, stringifyedValue);
} catch (e) {
return false;
}
return true;
}
/**
* Directly get a value from local storage
*
* @example
* ```
* localStorageService.get('library'); // returns 'angular'
* ```
*/
public get(key: string, prfx: string = prefix): any {
if (this.env.isServer) {
return null;
}
let item = this.localStorage.getItem(prfx + key);
item = item || this.localStorage.getItem(key);
try {
item = JSON.parse(item);
} catch (e) {
// do nothing, item already has the value
}
if (!item && item !== 0 && item !== '') {
return null;
}
return item;
}
/**
* Remove an item from local storage
*
* @example
* ```
* localStorageService.remove('library');
* // removes the key/value pair of library='angular';
* ```
*/
public remove(key: string, prfx: string = prefix): boolean {
if (this.env.isServer) {
return false;
}
try {
this.localStorage.removeItem(prfx + key);
} catch (e) {
return false;
}
return true;
}
/**
* Remove all data for this app from local storage
* Should be used mostly for development purposes
*
* @example
* ```
* localStorageService.clearAll();
* ```
*/
public clearAll(): boolean {
if (this.env.isServer) {
return false;
}
const prefixLength = prefix.length;
for (const key in this.localStorage) {
// Only remove items that are for this app
if (key.substr(0, prefixLength) === prefix) {
try {
this.remove(key.substr(prefixLength));
} catch (e) {
return false;
}
}
}
return true;
}
}
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { getProviders } from './providers.browser'
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic(getProviders()).bootstrapModule(AppModule);
import { StaticProvider } from '@angular/core';
import { environment } from './environments/environment';
export function loadJquery() {
return import('jquery');
}
export function getProviders(): StaticProvider[] {
return [
{
provide: 'env',
useValue: environment
},
{
provide: 'browser:jquery',
useFactory: loadJquery,
deps: []
}
];
}
import { StaticProvider } from '@angular/core';
const cookies = require('cookies');
import { environment } from './environments/environment';
export function getProviders(options): StaticProvider[] {
return [
{
provide: 'request',
useValue: options.req
},
{
provide: 'response',
useValue: options.res
},
{
provide: 'env',
useValue: { ...environment, ...process.env }
},
{
provide: 'node:cookies',
useValue: cookies
}
];
}
import 'zone.js/dist/zone-node';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { join } from 'path';
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { getProviders } from './providers.server.ts';
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
const server = express();
const distFolder = join(process.cwd(), 'dist/express-engine-ivy/browser');
server.engine('html', ngExpressEngine({
bootstrap: AppServerModule
}));
server.set('view engine', 'html');
server.set('views', distFolder);
// TODO: implement data requests securely
server.get('/api/*', (req, res) => {
res.status(404).send('data requests are not supported');
});
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {
maxAge: '1y'
}));
// All regular routes use the Universal engine
server.get('*', (req, res) => {
res.render('index', {
req,
res,
providers: [
{ provide: APP_BASE_HREF, useValue: req.baseUrl },
...getProviders({req, res})
]
});
});
return server;
}
function run() {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
if (mainModule && mainModule.filename === __filename) {
run();
}
export * from './src/main.server';
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment