front/SvelteViewEngine: pre-compile ssr on demand, refactor globals into proper locals and lazy locals

This commit is contained in:
Alice Gaudon 2021-11-24 22:08:38 +01:00
parent 2b85bea9dd
commit 9980c54fcf
23 changed files with 175 additions and 409 deletions

View File

@ -21,6 +21,8 @@ import SecurityError from "./SecurityError.js";
import {doesFileExist, Type} from "./Utils.js"; import {doesFileExist, Type} from "./Utils.js";
import WebSocketListener from "./WebSocketListener.js"; import WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError; import TemplateError = nunjucks.lib.TemplateError;
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js";
import LazyLocalsCoreComponent from "./components/core/LazyLocalsCoreComponent.js";
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> { export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string; private readonly version: string;
@ -98,6 +100,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
MysqlConnectionManager.registerMigrations(this.getMigrations()); MysqlConnectionManager.registerMigrations(this.getMigrations());
// Register and initialize all components and alike // Register and initialize all components and alike
this.use(new AppLocalsCoreComponents());
this.use(new LazyLocalsCoreComponent());
await this.init(); await this.init();
for (const component of this.components) { for (const component of this.components) {
await component.init?.(); await component.init?.();

View File

@ -6,11 +6,11 @@
import Icon from "../utils/Icon.svelte"; import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing"; import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.previousFormData()?.['auth_method'] !== 'password'; let registerUsingMagicLink = $locals.previousFormData?.['auth_method'] !== 'password';
let loginUsingMagicLink = true; let loginUsingMagicLink = true;
let queryStr = ''; let queryStr = '';
let previousUrl = $locals.getPreviousUrl(); let previousUrl: string = $locals.previousUrl as string | undefined;
if ($locals.query?.redirect_uri) { if ($locals.query?.redirect_uri) {
queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString(); queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString();
} else if (previousUrl) { } else if (previousUrl) {

View File

@ -2,7 +2,7 @@
import {locals} from "../../ts/stores.js"; import {locals} from "../../ts/stores.js";
import Message from "./Message.svelte"; import Message from "./Message.svelte";
export let flashed = $locals.flash(); export let flashed = $locals.flash;
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -4,7 +4,7 @@
import Icon from "../utils/Icon.svelte"; import Icon from "../utils/Icon.svelte";
import CommonScripts from "./CommonScripts.svelte"; import CommonScripts from "./CommonScripts.svelte";
const previousUrl = $locals.getPreviousUrl(); const previousUrl = $locals.previousUrl;
export let code; export let code;
code = $locals.error_code || code; code = $locals.error_code || code;

View File

@ -12,13 +12,7 @@
import * as stores from '/js/stores.js'; import * as stores from '/js/stores.js';
const localStore = stores.l; const localStore = stores.l;
const localMap = %locals%; localStore.set(%locals%);
localStore.set((key, args) => {
return localMap[args ?
`'${key}', \`${args}\``
: `'${key}'`];
});
setRoutes(%routes%); setRoutes(%routes%);
setPublicUrl(`%publicUrl%`); setPublicUrl(`%publicUrl%`);

View File

@ -3,4 +3,4 @@
import Field from "./Field.svelte"; import Field from "./Field.svelte";
</script> </script>
<Field type="hidden" name="csrf" value={$locals.getCsrfToken()}/> <Field type="hidden" name="csrf" value={$locals.csrfToken}/>

View File

@ -15,12 +15,12 @@
export let hint: string | undefined = undefined; export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined; export let extraData: string[] | undefined = undefined;
export let icon: string | undefined = undefined; export let icon: string | undefined = undefined;
export let validation: { message: string, value?: string } | undefined = $locals.validation()?.[name]; export let validation = $locals.validation?.[name] as { message: string, value?: string } | undefined;
const formId = getContext('formId'); const formId = getContext('formId');
const fieldId = `${formId}-${name}-field`; const fieldId = `${formId}-${name}-field`;
const previousFormData = $locals.previousFormData() || []; const previousFormData = $locals.previousFormData as FieldValue | undefined || [];
let previousFieldData = previousFormData[name]; let previousFieldData = previousFormData[name];
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData); if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);

View File

@ -5,26 +5,10 @@ import {Session, SessionData} from "express-session";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import {AuthMiddleware} from "../auth/AuthComponent.js"; import {AuthMiddleware} from "../auth/AuthComponent.js";
import {BadRequestError} from "../HttpError.js"; import {BadRequestError} from "../HttpError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
export default class CsrfProtectionComponent extends ApplicationComponent { export default class CsrfProtectionComponent extends ApplicationComponent {
private static readonly excluders: ((req: Request) => boolean)[] = []; private static readonly excluders: ((req: Request) => boolean)[] = [];
public static getCsrfToken(session: Session & Partial<SessionData>): string {
if (typeof session.csrf !== 'string') {
session.csrf = crypto.randomBytes(64).toString('base64');
}
return session.csrf;
}
public async init(): Promise<void> {
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
if (globals) {
globals.set('getCsrfToken', () => null);
}
}
public static addExcluder(excluder: (req: Request) => boolean): void { public static addExcluder(excluder: (req: Request) => boolean): void {
this.excluders.push(excluder); this.excluders.push(excluder);
} }
@ -36,9 +20,7 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
} }
const session = req.getSession(); const session = req.getSession();
res.locals.getCsrfToken = () => { res.setLazyLocal('csrfToken', () => this.getSessionCsrfToken(session));
return CsrfProtectionComponent.getCsrfToken(session);
};
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
try { try {
@ -58,6 +40,13 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
next(); next();
}); });
} }
public getSessionCsrfToken(session: Session & Partial<SessionData>): string {
if (typeof session.csrf !== 'string') {
session.csrf = crypto.randomBytes(64).toString('base64');
}
return session.csrf;
}
} }
class InvalidCsrfTokenError extends BadRequestError { class InvalidCsrfTokenError extends BadRequestError {

View File

@ -1,39 +1,20 @@
import {Router} from "express"; import {Router} from "express";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
export default class FormHelperComponent extends ApplicationComponent { export default class FormHelperComponent extends ApplicationComponent {
public async init(): Promise<void> {
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
if (globals) {
globals.set('validation', () => ({}));
globals.set('previousFormData', () => ({}));
}
}
public async initRoutes(router: Router): Promise<void> { public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => { router.use((req, res, next) => {
let _validation: unknown | null; res.setLazyLocal('validation', () => {
res.locals.validation = () => { const validation = req.flash('validation');
if (!_validation) { return validation.length > 0 ? validation[0] : null;
const v = req.flash('validation'); });
_validation = v.length > 0 ? v[0] : null;
}
return _validation; res.setLazyLocal('previousFormData', () => {
}; const previousFormData = req.flash('previousFormData');
return previousFormData.length > 0 ? previousFormData[0] : null;
let _previousFormData: unknown | null = null; });
res.locals.previousFormData = () => {
if (!_previousFormData) {
const v = req.flash('previousFormData');
_previousFormData = v.length > 0 ? v[0] : null;
}
return _previousFormData;
};
next(); next();
}); });

View File

@ -1,23 +1,14 @@
import config from "config";
import {Express, Router} from "express"; import {Express, Router} from "express";
import path from "path";
import util from "util";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import {QueryParams, route, RouteParams} from "../common/Routing.js";
import AssetCompiler from "../frontend/AssetCompiler.js"; import AssetCompiler from "../frontend/AssetCompiler.js";
import AssetPreCompiler from "../frontend/AssetPreCompiler.js"; import AssetPreCompiler from "../frontend/AssetPreCompiler.js";
import Globals from "../frontend/Globals.js";
import ViewEngine from "../frontend/ViewEngine.js"; import ViewEngine from "../frontend/ViewEngine.js";
import {logger} from "../Logger.js"; import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
import {listFilesRecursively} from "../Utils.js";
import FileCache from "../utils/FileCache.js";
export default class FrontendToolsComponent extends ApplicationComponent { export default class FrontendToolsComponent extends ApplicationComponent {
private readonly publicDir: string; private readonly publicDir: string;
private readonly publicAssetsCache: FileCache = new FileCache();
private readonly assetPreCompilers: AssetPreCompiler[]; private readonly assetPreCompilers: AssetPreCompiler[];
private readonly globals: Globals = new Globals();
public constructor( public constructor(
private readonly assetCompiler: AssetCompiler, private readonly assetCompiler: AssetCompiler,
@ -31,48 +22,17 @@ export default class FrontendToolsComponent extends ApplicationComponent {
if (assetPreCompiler.isPublic()) { if (assetPreCompiler.isPublic()) {
this.assetCompiler.addExtension(assetPreCompiler.getExtension()); this.assetCompiler.addExtension(assetPreCompiler.getExtension());
} }
assetPreCompiler.setGlobals(this.globals);
} }
} }
public async init(): Promise<void> {
this.globals.set('route', (
routeName: string,
params: RouteParams = [],
query: QueryParams = '',
absolute: boolean = false,
) => route(routeName, params, query, absolute));
this.globals.set('app_version', this.getApp().getVersion());
this.globals.set('core_version', this.getApp().getCoreVersion());
this.globals.set('app', config.get('app'));
this.globals.set('dump', (val: unknown) => {
return util.inspect(val);
});
this.globals.set('hex', (v: number) => {
return v.toString(16);
});
}
public async start(app: Express): Promise<void> { public async start(app: Express): Promise<void> {
// Cache public assets
if (config.get<boolean>('asset_cache')) {
logger.info('Caching assets from', this.publicDir, '...');
for (const file of await listFilesRecursively(this.publicDir)) {
await this.publicAssetsCache.load(file);
}
} else {
logger.info('Asset cache disabled.');
}
this.hookPreCompilers(); this.hookPreCompilers();
// Setup express view engine // Setup express view engine
let main = true; let main = true;
for (const assetPreCompiler of this.assetPreCompilers) { for (const assetPreCompiler of this.assetPreCompilers) {
if (assetPreCompiler instanceof ViewEngine) { if (assetPreCompiler instanceof ViewEngine) {
assetPreCompiler.setup(app, main); assetPreCompiler.setup(app, main, this.getApp().as(LazyLocalsCoreComponent));
main = false; main = false;
} }
} }
@ -84,17 +44,9 @@ export default class FrontendToolsComponent extends ApplicationComponent {
} }
} }
public async handleRoutes(router: Router): Promise<void> { public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => {
res.locals.inlineAsset = (urlPath: string) => {
return this.publicAssetsCache.getOrFail(path.join(this.publicDir, urlPath));
};
next();
});
// Add request context locals
router.use((req, res, next) => { router.use((req, res, next) => {
// Request context locals
res.locals.url = req.url; res.locals.url = req.url;
res.locals.params = req.params; res.locals.params = req.params;
res.locals.query = req.query; res.locals.query = req.query;
@ -126,8 +78,4 @@ export default class FrontendToolsComponent extends ApplicationComponent {
this.hookPreCompilers(); this.hookPreCompilers();
} }
} }
public getGlobals(): Globals {
return this.globals;
}
} }

View File

@ -10,10 +10,11 @@ import {logger} from "../Logger.js";
import Mail from "../mail/Mail.js"; import Mail from "../mail/Mail.js";
import MailError from "../mail/MailError.js"; import MailError from "../mail/MailError.js";
import SecurityError from "../SecurityError.js"; import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js"; import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
export default class MailComponent extends ApplicationComponent { export default class MailComponent extends ApplicationComponent {
private transporter?: Transporter; private transporter?: Transporter;
private readonly additionalLocals: Record<string, unknown> = {};
public constructor( public constructor(
private readonly viewEngine: MailViewEngine, private readonly viewEngine: MailViewEngine,
@ -59,7 +60,7 @@ export default class MailComponent extends ApplicationComponent {
logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`); logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
}); });
await this.viewEngine.setup(app, false); await this.viewEngine.setup(app, false, this.getApp().as(LazyLocalsCoreComponent));
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
@ -77,7 +78,6 @@ export default class MailComponent extends ApplicationComponent {
for (const destEmail of to) { for (const destEmail of to) {
const template = mail.getTemplate(); const template = mail.getTemplate();
const locals = mail.getData();
const options = mail.getOptions(); const options = mail.getOptions();
// Reset options // Reset options
@ -91,17 +91,17 @@ export default class MailComponent extends ApplicationComponent {
}; };
// Set locals // Set locals
locals.mail_subject = options.subject; const urlLocals = mail.getData();
locals.mail_to = options.to; urlLocals.mail_subject = options.subject;
locals.mail_link = config.get<string>('app.public_url') + urlLocals.mail_to = options.to;
route('mail', [template.template], locals); urlLocals.mail_link = config.get<string>('app.public_url') +
Object.assign(locals, this.getApp().as(FrontendToolsComponent).getGlobals().get()); route('mail', [template.template], urlLocals);
const locals = {...this.additionalLocals, ...urlLocals};
// Log // Log
logger.debug(`Send mail from ${options.from.address} to ${options.to}`); logger.debug(`Send mail from ${options.from.address} to ${options.to}`);
// Render email // Render email
options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false); options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false);
options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true); options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true);
@ -112,6 +112,10 @@ export default class MailComponent extends ApplicationComponent {
return results; return results;
} }
public setAdditionalLocal(key: string, value: unknown): void {
this.additionalLocals[key] = value;
}
private getTransporter(): Transporter { private getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.'); if (!this.transporter) throw new MailError('Mail system was not prepared.');
return this.transporter; return this.transporter;

View File

@ -3,17 +3,9 @@ import onFinished from "on-finished";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import SessionComponent from "./SessionComponent.js"; import SessionComponent from "./SessionComponent.js";
export default class PreviousUrlComponent extends ApplicationComponent { export default class PreviousUrlComponent extends ApplicationComponent {
public async init(): Promise<void> {
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
if (globals) {
globals.set('getPreviousUrl', () => null);
}
}
public async handleRoutes(router: Router): Promise<void> { public async handleRoutes(router: Router): Promise<void> {
router.use((req, res, next) => { router.use((req, res, next) => {
req.getPreviousUrl = () => { req.getPreviousUrl = () => {
@ -31,7 +23,7 @@ export default class PreviousUrlComponent extends ApplicationComponent {
return null; return null;
}; };
res.locals.getPreviousUrl = req.getPreviousUrl; res.setLazyLocal('previousUrl', () => req.getPreviousUrl());
req.getIntendedUrl = () => { req.getIntendedUrl = () => {
return req.query.redirect_uri?.toString() || null; return req.query.redirect_uri?.toString() || null;

View File

@ -5,7 +5,6 @@ import session from "express-session";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import SecurityError from "../SecurityError.js"; import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js"; import RedisComponent from "./RedisComponent.js";
export default class SessionComponent extends ApplicationComponent { export default class SessionComponent extends ApplicationComponent {
@ -16,13 +15,6 @@ export default class SessionComponent extends ApplicationComponent {
this.storeComponent = storeComponent; this.storeComponent = storeComponent;
} }
public async init(): Promise<void> {
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
if (globals) {
globals.set('flash', () => '');
}
}
public async checkSecuritySettings(): Promise<void> { public async checkSecuritySettings(): Promise<void> {
this.checkSecurityConfigField('session.secret'); this.checkSecurityConfigField('session.secret');
if (!config.get<boolean>('session.cookie.secure')) { if (!config.get<boolean>('session.cookie.secure')) {
@ -68,40 +60,16 @@ export default class SessionComponent extends ApplicationComponent {
res.locals.session = session; res.locals.session = session;
// Views flash function // Views flash function
const _flash: FlashStorage = {}; res.setLazyLocal('flash', () => {
res.locals.flash = (key?: string): FlashMessages | unknown[] => { return {
if (key !== undefined) {
if (_flash[key] === undefined) _flash[key] = req.flash(key);
return _flash[key] || [];
}
if (_flash._messages === undefined) {
_flash._messages = {
info: req.flash('info'), info: req.flash('info'),
success: req.flash('success'), success: req.flash('success'),
warning: req.flash('warning'), warning: req.flash('warning'),
error: req.flash('error'), error: req.flash('error'),
'error-alert': req.flash('error-alert'), 'error-alert': req.flash('error-alert'),
}; };
} });
return _flash._messages;
};
next(); next();
}); });
} }
} }
export type FlashMessages = {
[k: string]: unknown[] | undefined
};
export type DefaultFlashMessages = FlashMessages & {
info?: unknown[] | undefined;
success?: unknown[] | undefined;
warning?: unknown[] | undefined;
error?: unknown[] | undefined;
};
type FlashStorage = FlashMessages & {
_messages?: DefaultFlashMessages,
};

View File

@ -1,7 +1,7 @@
import config from "config"; import config from "config";
import cookie from "cookie"; import cookie from "cookie";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import {Express, Request} from "express"; import {Express, Request, Router} from "express";
import {Session} from "express-session"; import {Session} from "express-session";
import {WebSocketServer} from "ws"; import {WebSocketServer} from "ws";
@ -10,7 +10,6 @@ import ApplicationComponent from "../ApplicationComponent.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import WebSocketListener from "../WebSocketListener.js"; import WebSocketListener from "../WebSocketListener.js";
import ExpressAppComponent from "./ExpressAppComponent.js"; import ExpressAppComponent from "./ExpressAppComponent.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js"; import RedisComponent from "./RedisComponent.js";
export default class WebSocketServerComponent extends ApplicationComponent { export default class WebSocketServerComponent extends ApplicationComponent {
@ -21,11 +20,13 @@ export default class WebSocketServerComponent extends ApplicationComponent {
app.require(ExpressAppComponent); app.require(ExpressAppComponent);
app.require(RedisComponent); app.require(RedisComponent);
const globals = app.asOptional(FrontendToolsComponent)?.getGlobals();
if (globals) {
globals.set('websocketUrl', config.get('app.public_websocket_url'));
} }
public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => {
res.locals.websocketUrl = config.get('app.public_websocket_url');
next();
});
} }
public async start(_app: Express): Promise<void> { public async start(_app: Express): Promise<void> {

View File

@ -0,0 +1,26 @@
import config from "config";
import {Router} from "express";
import ApplicationComponent from "../../ApplicationComponent.js";
import MailComponent from "../MailComponent.js";
export default class AppLocalsCoreComponents extends ApplicationComponent {
private appLocals: Record<string, unknown> | undefined;
public async init(): Promise<void> {
this.appLocals = {
...config.get('app'),
version: this.getApp().getVersion(),
core_version: this.getApp().getCoreVersion(),
};
this.getApp().as(MailComponent).setAdditionalLocal('app', this.appLocals);
}
public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => {
res.locals.app = this.appLocals;
next();
});
}
}

View File

@ -0,0 +1,30 @@
import {Router} from "express";
import ApplicationComponent from "../../ApplicationComponent.js";
export default class LazyLocalsCoreComponent extends ApplicationComponent {
public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => {
res.locals._lazyLocals = {};
res.setLazyLocal = (key: string, valueProvider: () => unknown) => {
res.locals._lazyLocals[key] = valueProvider;
};
next();
});
}
public setupLazyLocals(localsObject: Record<string, unknown>): void {
const lazyLocals = localsObject._lazyLocals as Record<string, () => unknown> | undefined;
if (!lazyLocals) throw new Error('No _lazyLocals field found on referenced object.');
for (const lazyLocal of Object.keys(lazyLocals)) {
Object.defineProperty(localsObject, lazyLocal, {
get: function () {
delete this[lazyLocal];
return this[lazyLocal] = lazyLocals[lazyLocal]();
},
configurable: true,
});
}
}
}

View File

@ -4,11 +4,9 @@ import path from "path";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import {doesFileExist, listFilesRecursively} from "../Utils.js"; import {doesFileExist, listFilesRecursively} from "../Utils.js";
import Globals from "./Globals.js";
export default abstract class AssetPreCompiler { export default abstract class AssetPreCompiler {
protected readonly assetPaths: string[]; protected readonly assetPaths: string[];
private globals?: Globals;
private watcher?: FSWatcher; private watcher?: FSWatcher;
private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = []; private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = [];
private inputChangeHandler?: (restart: boolean) => Promise<void>; private inputChangeHandler?: (restart: boolean) => Promise<void>;
@ -57,15 +55,6 @@ export default abstract class AssetPreCompiler {
return this.assetPaths; return this.assetPaths;
} }
protected getGlobals(): Globals {
if (!this.globals) throw new Error('globals field not intialized.');
return this.globals;
}
public setGlobals(globals: Globals): void {
this.globals = globals;
}
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (this.watcher) { if (this.watcher) {
await this.watcher.close(); await this.watcher.close();

View File

@ -1,11 +0,0 @@
export default class Globals {
private readonly globals: Record<string, unknown> = {};
public get(): Record<string, unknown> {
return {...this.globals};
}
public set(key: string, value: unknown): void {
this.globals[key] = value;
}
}

View File

@ -2,7 +2,6 @@ import "./register_svelte/register_svelte.js";
import clearModule from "clear-module"; import clearModule from "clear-module";
import config from "config"; import config from "config";
import crypto from "crypto";
import {promises as fs} from 'fs'; import {promises as fs} from 'fs';
import path from "path"; import path from "path";
import requireFromString from "require-from-string"; import requireFromString from "require-from-string";
@ -14,23 +13,10 @@ import {logger} from "../Logger.js";
import FileCache from "../utils/FileCache.js"; import FileCache from "../utils/FileCache.js";
import ViewEngine from "./ViewEngine.js"; import ViewEngine from "./ViewEngine.js";
const BACKEND_CODE_PREFIX = '$locals.';
const BACKEND_CODE_PREFIX_TEMPORARY_HOLDER = '$$locals$$';
const COMPILED_SVELTE_EXTENSION = '.precompiled';
export default class SvelteViewEngine extends ViewEngine { export default class SvelteViewEngine extends ViewEngine {
public static getPreCompileSeparator(canonicalViewName: string): string {
return '\n---' +
crypto.createHash('sha1')
.update(path.basename(path.resolve(canonicalViewName)))
.digest('base64') +
'---\n';
}
private readonly fileCache: FileCache = new FileCache(); private readonly fileCache: FileCache = new FileCache();
private readonly dependencyCache: Record<string, Set<string>> = {}; private readonly reverseDependencyCache: Record<string, Set<string>> = {};
private readonly preprocessingCache: Record<string, PreprocessingCacheEntry> = {}; private readonly preprocessingCache: Record<string, string> = {};
private readonly cssCache: Record<string, string[] | undefined> = {}; private readonly cssCache: Record<string, string[] | undefined> = {};
public constructor( public constructor(
@ -48,8 +34,8 @@ export default class SvelteViewEngine extends ViewEngine {
public async onFileRemove(file: string): Promise<void> { public async onFileRemove(file: string): Promise<void> {
const canonicalName = this.toCanonicalName(file); const canonicalName = this.toCanonicalName(file);
delete this.preprocessingCache[canonicalName]; delete this.preprocessingCache[canonicalName];
delete this.dependencyCache[canonicalName]; delete this.reverseDependencyCache[canonicalName];
Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName)); Object.values(this.reverseDependencyCache).forEach(set => set.delete(canonicalName));
await super.onFileRemove(file); await super.onFileRemove(file);
} }
@ -59,34 +45,24 @@ export default class SvelteViewEngine extends ViewEngine {
): Promise<string> { ): Promise<string> {
const canonicalViewName = this.toCanonicalName(file); const canonicalViewName = this.toCanonicalName(file);
// View const rootTemplateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html');
const actualFile = path.join(this.targetDir, canonicalViewName + COMPILED_SVELTE_EXTENSION); const rawOutput = await this.fileCache.get(rootTemplateFile, !config.get<boolean>('view.cache'));
const view = await this.fileCache.get(actualFile, !config.get<boolean>('view.cache'));
// Root template const {
const templateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html');
const rawOutput = await this.fileCache.get(templateFile, !config.get<boolean>('view.cache'));
// Pre-compiled parts
const [
backendCalls,
head, head,
html, html,
css, css,
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName)); } = await this.renderSvelteSsr(canonicalViewName, locals);
const localMap: Record<string, unknown> = this.compileBackendCalls(backendCalls.split('\n'), locals, false); const serializedLocals = JSON.stringify(locals, (key, value) => {
const actualLocals = JSON.stringify(localMap, (key, value) => { if (key.startsWith('_') || typeof value === 'function') return undefined;
if (key.startsWith('_')) return undefined; return value;
return typeof value === 'function' ?
value.toString() :
value;
}); });
// Replaces // Replaces
const replaceMap: Record<string, string> = { const replaceMap: Record<string, string> = {
canonicalViewName: canonicalViewName, canonicalViewName: canonicalViewName,
locals: actualLocals, locals: serializedLocals,
head: head, head: head,
html: html, html: html,
css: css, css: css,
@ -104,55 +80,17 @@ export default class SvelteViewEngine extends ViewEngine {
const targetFile = path.join(this.targetDir, canonicalName); const targetFile = path.join(this.targetDir, canonicalName);
logger.info(canonicalName + ' > ', 'Pre-compiling', canonicalName, '->', targetFile); logger.info(canonicalName + ' > ', 'Pre-compiling', canonicalName, '->', targetFile);
const {backendCalls} = await this.preprocess(canonicalName); await this.preprocessSvelte(canonicalName);
// Server Side Render (initial HTML and CSS, no-js) if (alsoCompileDependents && Object.keys(this.reverseDependencyCache).indexOf(canonicalName) >= 0) {
const ssr = await this.compileSsr(canonicalName); logger.info(canonicalName + ' > ', 'Pre-compiling dependents...');
for (const dependent of [...this.reverseDependencyCache[canonicalName]]) {
const separator = SvelteViewEngine.getPreCompileSeparator(canonicalName);
const finalCode = [
[...new Set<string>(backendCalls).values()].join('\n'),
ssr.head,
ssr.html,
ssr.css,
].join(separator);
const swafViewFile = path.join(this.targetDir, canonicalName + COMPILED_SVELTE_EXTENSION);
await fs.mkdir(path.dirname(swafViewFile), {recursive: true});
await fs.writeFile(swafViewFile, finalCode);
if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) {
logger.info(canonicalName + ' > ', 'Compiling dependents...');
for (const dependent of [...this.dependencyCache[canonicalName]]) {
await this.preCompile(dependent, true); await this.preCompile(dependent, true);
} }
} }
} }
private resolveDependencies(source: string, canonicalViewName: string): string[] { private async preprocessSvelte(canonicalName: string): Promise<string> {
const dependencies: string[] = [];
for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
}
// Clear existing links from cache
for (const dependency of Object.keys(this.dependencyCache)) {
this.dependencyCache[dependency].delete(canonicalViewName);
}
// Add new links to cache
for (const dependency of dependencies) {
if (Object.keys(this.dependencyCache).indexOf(dependency) < 0) {
this.dependencyCache[dependency] = new Set<string>();
}
this.dependencyCache[dependency].add(canonicalViewName);
}
return dependencies;
}
private async preprocess(canonicalName: string): Promise<PreprocessingCacheEntry> {
// Cache // Cache
if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) { if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) {
return this.preprocessingCache[canonicalName]; return this.preprocessingCache[canonicalName];
@ -168,13 +106,10 @@ export default class SvelteViewEngine extends ViewEngine {
// Read source file if code was not already provided // Read source file if code was not already provided
const code = await this.fileCache.get(file, !config.get<boolean>('view.cache')); const code = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
// Replace backend calls
const replacedBackendCalls = await this.replaceBackendCalls(canonicalName, code);
// Preprocess svelte // Preprocess svelte
logger.info(canonicalName + ' > ', 'Svelte preprocessing'); logger.info(canonicalName + ' > ', 'Svelte preprocessing');
const preprocessed = await preprocess( const processed = await preprocess(
replacedBackendCalls.code, code,
sveltePreprocess({ sveltePreprocess({
typescript: { typescript: {
tsconfigFile: 'src/assets/views/tsconfig.json', tsconfigFile: 'src/assets/views/tsconfig.json',
@ -186,95 +121,26 @@ export default class SvelteViewEngine extends ViewEngine {
); );
// Write to output file // Write to output file
await fs.writeFile(outputFile, preprocessed.code); await fs.writeFile(outputFile, processed.code);
// Preprocess dependencies this.resolveAndCacheDependencies(processed.code, canonicalName);
const backendCalls: string[] = replacedBackendCalls.backendCalls;
for (const dependency of this.resolveDependencies(code, canonicalName)) { return this.preprocessingCache[canonicalName] = processed.code;
backendCalls.push(...(await this.preprocess(dependency)).backendCalls);
} }
return this.preprocessingCache[canonicalName] = { private async renderSvelteSsr(canonicalName: string, locals: {[key: string]: unknown}): Promise<{
backendCalls: backendCalls,
code: preprocessed.code,
};
}
private async replaceBackendCalls(canonicalName: string, code: string): Promise<PreprocessingCacheEntry> {
logger.info(canonicalName + ' > ', 'Replacing backend calls');
// Skip replace if there is no swaf export
if (!code.match(/import[ \n]+{[ \n]*locals[ \n]*}[ \n]+from[ \n]+["'](\.\.\/)+ts\/stores(\.js)?["']/)) {
return {
backendCalls: [],
code: code,
};
}
let output = code;
const backendCalls = new Set<string>();
let index = 0;
while ((index = output.indexOf(BACKEND_CODE_PREFIX, index + 1)) >= 0) {
// Escaping
if (index > 0 && output[index - 1] === '\\') {
const isEscapingEscaped: boolean = index > 1 && output[index - 2] === '\\';
output = output.substring(0, index - 1 - (isEscapingEscaped ? 1 : 0)) +
output.substring(index, output.length);
continue;
}
const startIndex = index + BACKEND_CODE_PREFIX.length;
let endIndex = startIndex;
let struct = 0;
while (endIndex < output.length) {
if (['(', '[', '{'].indexOf(output[endIndex]) >= 0) struct++;
if ([')', ']', '}'].indexOf(output[endIndex]) >= 0) {
struct--;
if (struct <= 0) {
if (struct === 0) endIndex++;
break;
}
}
if ([' ', '\n', '<', '.', '\'', '"', '?', ','].indexOf(output[endIndex]) >= 0 && struct === 0) break;
endIndex++;
}
let backendCall = output.substring(startIndex, endIndex);
if (backendCall.match(/([^()]+)\((.*?)\)/)) {
backendCall = backendCall.replace(/([^()]+)\((.*?)\)/, "'$1', `[$2]`");
} else {
backendCall = backendCall.replace(/([^()]+)(\(\))?/, "'$1'");
}
backendCalls.add(backendCall);
output = output.substring(0, index) +
'$locals(' + backendCall.split(BACKEND_CODE_PREFIX).join(BACKEND_CODE_PREFIX_TEMPORARY_HOLDER) + ')' +
output.substring(endIndex, output.length);
}
output = output.split(BACKEND_CODE_PREFIX_TEMPORARY_HOLDER).join(BACKEND_CODE_PREFIX);
return {
backendCalls: [...backendCalls],
code: output,
};
}
private async compileSsr(canonicalName: string): Promise<{
head: string, head: string,
css: string, css: string,
html: string, html: string,
}> { }> {
const targetFile = path.join(this.targetDir, canonicalName); const targetFile = path.join(this.targetDir, canonicalName);
const {backendCalls, code} = await this.preprocess(canonicalName); const code = await this.fileCache.get(targetFile, !config.get<boolean>('view.cache'));
// Get dependencies css // Get dependencies css
const dependenciesCss: string[] = []; const dependenciesCss: string[] = [];
for (const dependency of this.resolveDependencies(code, canonicalName)) { for (const dependency of this.resolveAndCacheDependencies(code, canonicalName)) {
if (this.cssCache[dependency] === undefined) { if (this.cssCache[dependency] === undefined) {
await this.compileSsr(dependency); await this.renderSvelteSsr(dependency, locals);
} }
const css = this.cssCache[dependency]; const css = this.cssCache[dependency];
if (css === undefined) { if (css === undefined) {
@ -295,15 +161,8 @@ export default class SvelteViewEngine extends ViewEngine {
}); });
// Load locals into locals store // Load locals into locals store
const localsModulePath = path.resolve(this.targetDir, "../ts/stores.js"); const storesModule = await import(path.resolve(this.targetDir, "../ts/stores.js"));
const localsModule = await import(localsModulePath); storesModule.locals.set(locals);
const locals = this.getGlobals().get();
const localMap = this.compileBackendCalls(backendCalls, locals, true);
localsModule.locals.set((key: string, args: string) => {
return localMap[args ?
`'${key}', \`${args}\``
: `'${key}'`];
});
// Load module and render // Load module and render
const moduleId = path.resolve(targetFile); const moduleId = path.resolve(targetFile);
@ -324,40 +183,26 @@ export default class SvelteViewEngine extends ViewEngine {
}; };
} }
private compileBackendCalls( private resolveAndCacheDependencies(source: string, canonicalViewName: string): string[] {
backendCalls: string[], const dependencies: string[] = [];
locals: Record<string, unknown>,
isPreRender: boolean,
): Record<string, unknown> {
locals = {...locals, isPreRender};
const localMap: Record<string, unknown> = {}; for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
backendCalls.forEach(code => { dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
const key = code.substring(1, code.indexOf(',') >= 0 ? code.indexOf(',') - 1 : code.length - 1);
if (code.indexOf('`[') >= 0) {
const args = code.substring(code.indexOf('`[') + 2, code.length - 2)
.split(/, *?/)
.map(arg => {
if (arg.startsWith("'")) return '"' + arg.substring(1, arg.length - 1) + '"';
return arg;
})
.filter(arg => arg.length > 0)
.map(arg => {
return Function(`"use strict";const $locals = arguments[0];return (${arg});`)(locals);
}); // Uses named parameter locals
const f = locals[key];
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
localMap[`'${key}', \`[${code.substring(code.indexOf('`[') + 2, code.length - 2)}]\``] = f.call(locals, ...args);
} else {
localMap[`'${key}'`] = locals[key];
} }
});
return localMap; // Clear existing links from cache
for (const dependency of Object.keys(this.reverseDependencyCache)) {
this.reverseDependencyCache[dependency].delete(canonicalViewName);
}
// Add new links to cache
for (const dependency of dependencies) {
if (Object.keys(this.reverseDependencyCache).indexOf(dependency) < 0) {
this.reverseDependencyCache[dependency] = new Set<string>();
}
this.reverseDependencyCache[dependency].add(canonicalViewName);
}
return dependencies;
} }
} }
type PreprocessingCacheEntry = {
backendCalls: string[],
code: string,
};

View File

@ -1,5 +1,6 @@
import {Express} from "express"; import {Express} from "express";
import LazyLocalsCoreComponent from "../components/core/LazyLocalsCoreComponent.js";
import AssetPreCompiler from "./AssetPreCompiler.js"; import AssetPreCompiler from "./AssetPreCompiler.js";
export default abstract class ViewEngine extends AssetPreCompiler { export default abstract class ViewEngine extends AssetPreCompiler {
@ -18,10 +19,11 @@ export default abstract class ViewEngine extends AssetPreCompiler {
locals: Record<string, unknown>, locals: Record<string, unknown>,
): Promise<string>; ): Promise<string>;
public setup(app: Express, main: boolean): void { public setup(app: Express, main: boolean, lazyLocalsComponent: LazyLocalsCoreComponent): void {
app.engine(this.extension, (path, options, callback) => { app.engine(this.extension, (path, options, callback) => {
// Props (locals) const locals = {...options};
const locals = Object.assign(this.getGlobals().get(), options);
lazyLocalsComponent.setupLazyLocals(locals);
this.render(path, locals) this.render(path, locals)
.then(value => callback(null, value)) .then(value => callback(null, value))

View File

@ -33,6 +33,10 @@ declare global {
getIntendedUrl(): string | null; getIntendedUrl(): string | null;
} }
export interface Response {
setLazyLocal(key: string, valueProvider: () => unknown): void;
}
} }
} }

View File

@ -12,7 +12,7 @@ useApp(async (addr, port) => {
this.use(new class extends Controller { this.use(new class extends Controller {
public routes(): void { public routes(): void {
this.get('/', (req, res) => { this.get('/', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession())); res.send(this.getApp().as(CsrfProtectionComponent).getSessionCsrfToken(req.getSession()));
}, 'csrf_test'); }, 'csrf_test');
this.post('/', (req, res) => { this.post('/', (req, res) => {

View File

@ -58,7 +58,7 @@ export function authAppProvider(withUsername: boolean = true, approvalMode: bool
res.render('home'); res.render('home');
}, 'home'); }, 'home');
this.get('/csrf', (req, res) => { this.get('/csrf', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession())); res.send(this.getApp().as(CsrfProtectionComponent).getSessionCsrfToken(req.getSession()));
}, 'csrf'); }, 'csrf');
this.get('/is-auth', async (req, res) => { this.get('/is-auth', async (req, res) => {
const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req); const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req);