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 WebSocketListener from "./WebSocketListener.js";
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>> {
private readonly version: string;
@ -98,6 +100,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
MysqlConnectionManager.registerMigrations(this.getMigrations());
// Register and initialize all components and alike
this.use(new AppLocalsCoreComponents());
this.use(new LazyLocalsCoreComponent());
await this.init();
for (const component of this.components) {
await component.init?.();

View File

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

View File

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

View File

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

View File

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

View File

@ -3,4 +3,4 @@
import Field from "./Field.svelte";
</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 extraData: 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 fieldId = `${formId}-${name}-field`;
const previousFormData = $locals.previousFormData() || [];
const previousFormData = $locals.previousFormData as FieldValue | undefined || [];
let previousFieldData = previousFormData[name];
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 {AuthMiddleware} from "../auth/AuthComponent.js";
import {BadRequestError} from "../HttpError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
export default class CsrfProtectionComponent extends ApplicationComponent {
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 {
this.excluders.push(excluder);
}
@ -36,9 +20,7 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
}
const session = req.getSession();
res.locals.getCsrfToken = () => {
return CsrfProtectionComponent.getCsrfToken(session);
};
res.setLazyLocal('csrfToken', () => this.getSessionCsrfToken(session));
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
try {
@ -58,6 +40,13 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
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 {

View File

@ -1,39 +1,20 @@
import {Router} from "express";
import ApplicationComponent from "../ApplicationComponent.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
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> {
router.use((req, res, next) => {
let _validation: unknown | null;
res.locals.validation = () => {
if (!_validation) {
const v = req.flash('validation');
_validation = v.length > 0 ? v[0] : null;
}
res.setLazyLocal('validation', () => {
const validation = req.flash('validation');
return validation.length > 0 ? validation[0] : null;
});
return _validation;
};
let _previousFormData: unknown | null = null;
res.locals.previousFormData = () => {
if (!_previousFormData) {
const v = req.flash('previousFormData');
_previousFormData = v.length > 0 ? v[0] : null;
}
return _previousFormData;
};
res.setLazyLocal('previousFormData', () => {
const previousFormData = req.flash('previousFormData');
return previousFormData.length > 0 ? previousFormData[0] : null;
});
next();
});

View File

@ -1,23 +1,14 @@
import config from "config";
import {Express, Router} from "express";
import path from "path";
import util from "util";
import ApplicationComponent from "../ApplicationComponent.js";
import {QueryParams, route, RouteParams} from "../common/Routing.js";
import AssetCompiler from "../frontend/AssetCompiler.js";
import AssetPreCompiler from "../frontend/AssetPreCompiler.js";
import Globals from "../frontend/Globals.js";
import ViewEngine from "../frontend/ViewEngine.js";
import {logger} from "../Logger.js";
import {listFilesRecursively} from "../Utils.js";
import FileCache from "../utils/FileCache.js";
import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
export default class FrontendToolsComponent extends ApplicationComponent {
private readonly publicDir: string;
private readonly publicAssetsCache: FileCache = new FileCache();
private readonly assetPreCompilers: AssetPreCompiler[];
private readonly globals: Globals = new Globals();
public constructor(
private readonly assetCompiler: AssetCompiler,
@ -31,48 +22,17 @@ export default class FrontendToolsComponent extends ApplicationComponent {
if (assetPreCompiler.isPublic()) {
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> {
// 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();
// Setup express view engine
let main = true;
for (const assetPreCompiler of this.assetPreCompilers) {
if (assetPreCompiler instanceof ViewEngine) {
assetPreCompiler.setup(app, main);
assetPreCompiler.setup(app, main, this.getApp().as(LazyLocalsCoreComponent));
main = false;
}
}
@ -84,17 +44,9 @@ export default class FrontendToolsComponent extends ApplicationComponent {
}
}
public async handleRoutes(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
public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => {
// Request context locals
res.locals.url = req.url;
res.locals.params = req.params;
res.locals.query = req.query;
@ -126,8 +78,4 @@ export default class FrontendToolsComponent extends ApplicationComponent {
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 MailError from "../mail/MailError.js";
import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
export default class MailComponent extends ApplicationComponent {
private transporter?: Transporter;
private readonly additionalLocals: Record<string, unknown> = {};
public constructor(
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')}`);
});
await this.viewEngine.setup(app, false);
await this.viewEngine.setup(app, false, this.getApp().as(LazyLocalsCoreComponent));
}
public async stop(): Promise<void> {
@ -77,7 +78,6 @@ export default class MailComponent extends ApplicationComponent {
for (const destEmail of to) {
const template = mail.getTemplate();
const locals = mail.getData();
const options = mail.getOptions();
// Reset options
@ -91,17 +91,17 @@ export default class MailComponent extends ApplicationComponent {
};
// Set locals
locals.mail_subject = options.subject;
locals.mail_to = options.to;
locals.mail_link = config.get<string>('app.public_url') +
route('mail', [template.template], locals);
Object.assign(locals, this.getApp().as(FrontendToolsComponent).getGlobals().get());
const urlLocals = mail.getData();
urlLocals.mail_subject = options.subject;
urlLocals.mail_to = options.to;
urlLocals.mail_link = config.get<string>('app.public_url') +
route('mail', [template.template], urlLocals);
const locals = {...this.additionalLocals, ...urlLocals};
// Log
logger.debug(`Send mail from ${options.from.address} to ${options.to}`);
// Render email
options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false);
options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true);
@ -112,6 +112,10 @@ export default class MailComponent extends ApplicationComponent {
return results;
}
public setAdditionalLocal(key: string, value: unknown): void {
this.additionalLocals[key] = value;
}
private getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.');
return this.transporter;

View File

@ -3,17 +3,9 @@ import onFinished from "on-finished";
import ApplicationComponent from "../ApplicationComponent.js";
import {logger} from "../Logger.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import SessionComponent from "./SessionComponent.js";
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> {
router.use((req, res, next) => {
req.getPreviousUrl = () => {
@ -31,7 +23,7 @@ export default class PreviousUrlComponent extends ApplicationComponent {
return null;
};
res.locals.getPreviousUrl = req.getPreviousUrl;
res.setLazyLocal('previousUrl', () => req.getPreviousUrl());
req.getIntendedUrl = () => {
return req.query.redirect_uri?.toString() || null;

View File

@ -5,7 +5,6 @@ import session from "express-session";
import ApplicationComponent from "../ApplicationComponent.js";
import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js";
export default class SessionComponent extends ApplicationComponent {
@ -16,13 +15,6 @@ export default class SessionComponent extends ApplicationComponent {
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> {
this.checkSecurityConfigField('session.secret');
if (!config.get<boolean>('session.cookie.secure')) {
@ -68,40 +60,16 @@ export default class SessionComponent extends ApplicationComponent {
res.locals.session = session;
// Views flash function
const _flash: FlashStorage = {};
res.locals.flash = (key?: string): FlashMessages | unknown[] => {
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'),
success: req.flash('success'),
warning: req.flash('warning'),
error: req.flash('error'),
'error-alert': req.flash('error-alert'),
};
}
return _flash._messages;
};
res.setLazyLocal('flash', () => {
return {
info: req.flash('info'),
success: req.flash('success'),
warning: req.flash('warning'),
error: req.flash('error'),
'error-alert': req.flash('error-alert'),
};
});
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 cookie from "cookie";
import cookieParser from "cookie-parser";
import {Express, Request} from "express";
import {Express, Request, Router} from "express";
import {Session} from "express-session";
import {WebSocketServer} from "ws";
@ -10,7 +10,6 @@ import ApplicationComponent from "../ApplicationComponent.js";
import {logger} from "../Logger.js";
import WebSocketListener from "../WebSocketListener.js";
import ExpressAppComponent from "./ExpressAppComponent.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js";
export default class WebSocketServerComponent extends ApplicationComponent {
@ -21,11 +20,13 @@ export default class WebSocketServerComponent extends ApplicationComponent {
app.require(ExpressAppComponent);
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> {

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 {doesFileExist, listFilesRecursively} from "../Utils.js";
import Globals from "./Globals.js";
export default abstract class AssetPreCompiler {
protected readonly assetPaths: string[];
private globals?: Globals;
private watcher?: FSWatcher;
private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = [];
private inputChangeHandler?: (restart: boolean) => Promise<void>;
@ -57,15 +55,6 @@ export default abstract class AssetPreCompiler {
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> {
if (this.watcher) {
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 config from "config";
import crypto from "crypto";
import {promises as fs} from 'fs';
import path from "path";
import requireFromString from "require-from-string";
@ -14,23 +13,10 @@ import {logger} from "../Logger.js";
import FileCache from "../utils/FileCache.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 {
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 dependencyCache: Record<string, Set<string>> = {};
private readonly preprocessingCache: Record<string, PreprocessingCacheEntry> = {};
private readonly reverseDependencyCache: Record<string, Set<string>> = {};
private readonly preprocessingCache: Record<string, string> = {};
private readonly cssCache: Record<string, string[] | undefined> = {};
public constructor(
@ -48,8 +34,8 @@ export default class SvelteViewEngine extends ViewEngine {
public async onFileRemove(file: string): Promise<void> {
const canonicalName = this.toCanonicalName(file);
delete this.preprocessingCache[canonicalName];
delete this.dependencyCache[canonicalName];
Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName));
delete this.reverseDependencyCache[canonicalName];
Object.values(this.reverseDependencyCache).forEach(set => set.delete(canonicalName));
await super.onFileRemove(file);
}
@ -59,34 +45,24 @@ export default class SvelteViewEngine extends ViewEngine {
): Promise<string> {
const canonicalViewName = this.toCanonicalName(file);
// View
const actualFile = path.join(this.targetDir, canonicalViewName + COMPILED_SVELTE_EXTENSION);
const view = await this.fileCache.get(actualFile, !config.get<boolean>('view.cache'));
const rootTemplateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html');
const rawOutput = await this.fileCache.get(rootTemplateFile, !config.get<boolean>('view.cache'));
// Root template
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,
const {
head,
html,
css,
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName));
} = await this.renderSvelteSsr(canonicalViewName, locals);
const localMap: Record<string, unknown> = this.compileBackendCalls(backendCalls.split('\n'), locals, false);
const actualLocals = JSON.stringify(localMap, (key, value) => {
if (key.startsWith('_')) return undefined;
return typeof value === 'function' ?
value.toString() :
value;
const serializedLocals = JSON.stringify(locals, (key, value) => {
if (key.startsWith('_') || typeof value === 'function') return undefined;
return value;
});
// Replaces
const replaceMap: Record<string, string> = {
canonicalViewName: canonicalViewName,
locals: actualLocals,
locals: serializedLocals,
head: head,
html: html,
css: css,
@ -104,55 +80,17 @@ export default class SvelteViewEngine extends ViewEngine {
const targetFile = path.join(this.targetDir, canonicalName);
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)
const ssr = await this.compileSsr(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]]) {
if (alsoCompileDependents && Object.keys(this.reverseDependencyCache).indexOf(canonicalName) >= 0) {
logger.info(canonicalName + ' > ', 'Pre-compiling dependents...');
for (const dependent of [...this.reverseDependencyCache[canonicalName]]) {
await this.preCompile(dependent, true);
}
}
}
private resolveDependencies(source: string, canonicalViewName: string): 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> {
private async preprocessSvelte(canonicalName: string): Promise<string> {
// Cache
if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) {
return this.preprocessingCache[canonicalName];
@ -168,13 +106,10 @@ export default class SvelteViewEngine extends ViewEngine {
// Read source file if code was not already provided
const code = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
// Replace backend calls
const replacedBackendCalls = await this.replaceBackendCalls(canonicalName, code);
// Preprocess svelte
logger.info(canonicalName + ' > ', 'Svelte preprocessing');
const preprocessed = await preprocess(
replacedBackendCalls.code,
const processed = await preprocess(
code,
sveltePreprocess({
typescript: {
tsconfigFile: 'src/assets/views/tsconfig.json',
@ -186,95 +121,26 @@ export default class SvelteViewEngine extends ViewEngine {
);
// Write to output file
await fs.writeFile(outputFile, preprocessed.code);
await fs.writeFile(outputFile, processed.code);
// Preprocess dependencies
const backendCalls: string[] = replacedBackendCalls.backendCalls;
for (const dependency of this.resolveDependencies(code, canonicalName)) {
backendCalls.push(...(await this.preprocess(dependency)).backendCalls);
}
this.resolveAndCacheDependencies(processed.code, canonicalName);
return this.preprocessingCache[canonicalName] = {
backendCalls: backendCalls,
code: preprocessed.code,
};
return this.preprocessingCache[canonicalName] = processed.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<{
private async renderSvelteSsr(canonicalName: string, locals: {[key: string]: unknown}): Promise<{
head: string,
css: string,
html: string,
}> {
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
const dependenciesCss: string[] = [];
for (const dependency of this.resolveDependencies(code, canonicalName)) {
for (const dependency of this.resolveAndCacheDependencies(code, canonicalName)) {
if (this.cssCache[dependency] === undefined) {
await this.compileSsr(dependency);
await this.renderSvelteSsr(dependency, locals);
}
const css = this.cssCache[dependency];
if (css === undefined) {
@ -295,15 +161,8 @@ export default class SvelteViewEngine extends ViewEngine {
});
// Load locals into locals store
const localsModulePath = path.resolve(this.targetDir, "../ts/stores.js");
const localsModule = await import(localsModulePath);
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}'`];
});
const storesModule = await import(path.resolve(this.targetDir, "../ts/stores.js"));
storesModule.locals.set(locals);
// Load module and render
const moduleId = path.resolve(targetFile);
@ -324,40 +183,26 @@ export default class SvelteViewEngine extends ViewEngine {
};
}
private compileBackendCalls(
backendCalls: string[],
locals: Record<string, unknown>,
isPreRender: boolean,
): Record<string, unknown> {
locals = {...locals, isPreRender};
private resolveAndCacheDependencies(source: string, canonicalViewName: string): string[] {
const dependencies: string[] = [];
const localMap: Record<string, unknown> = {};
backendCalls.forEach(code => {
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
for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
}
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];
// 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>();
}
});
return localMap;
this.reverseDependencyCache[dependency].add(canonicalViewName);
}
return dependencies;
}
}
type PreprocessingCacheEntry = {
backendCalls: string[],
code: string,
};

View File

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

View File

@ -33,6 +33,10 @@ declare global {
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 {
public routes(): void {
this.get('/', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession()));
res.send(this.getApp().as(CsrfProtectionComponent).getSessionCsrfToken(req.getSession()));
}, 'csrf_test');
this.post('/', (req, res) => {

View File

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