import {Router} from "express"; import * as fs from "fs"; import path from "path"; import config from "config"; import ApplicationComponent from "../ApplicationComponent"; import {logger} from "../Logger"; import {ServerError} from "../HttpError"; import * as crypto from "crypto"; import {compile, preprocess} from "svelte/compiler"; import requireFromString from "require-from-string"; import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess"; import chokidar from "chokidar"; import {CssResult} from "svelte/types/compiler/interfaces"; const BACKEND_CODE_PREFIX = 'swaf.'; const COMPILED_SVELTE_EXTENSION = '.swafview'; export default class FrontendToolsComponent extends ApplicationComponent { public static getSveltePreCompileSeparator(file: string): string { return '\n---' + crypto.createHash('sha1').update(path.basename(path.resolve(file))).digest('base64') + '---\n'; } private content: Map = new Map(); public constructor( private readonly publicAssetsPath: string, private readonly svelteViewsPath: string, private readonly svelteOutputPath: string, ) { super(); if (!fs.existsSync(svelteOutputPath)) { fs.mkdirSync(svelteOutputPath); } } public async start(): Promise { await this.cachePublicAssets(); await this.preCompileSvelteViews(); const watcher = chokidar.watch(this.svelteViewsPath, {persistent: true}); watcher.on('ready', () => { logger.debug('Watching svelte assets for changes'); watcher.on('add', (path) => { this.preCompileSvelte(path) .catch(logger.error); }); watcher.on('change', (path) => { this.preCompileSvelte(path) .catch(logger.error); }); }); } private async cachePublicAssets(): Promise { if (config.get('view.enable_asset_cache')) { logger.info('Caching assets from', this.publicAssetsPath, '...'); await this.forEachFileInDirRecursively(this.publicAssetsPath, file => this.cacheFile(file)); } else { logger.info('Asset cache disabled.'); } } private async preCompileSvelteViews(): Promise { logger.info('Pre-compiling svelte views...'); await this.forEachFileInDirRecursively(this.svelteViewsPath, async file => { if (file.endsWith('.svelte')) { await this.preCompileSvelte(file); } }); } public async handle(router: Router): Promise { router.use((req, res, next) => { res.locals.inlineAsset = (urlPath: string) => { if (!config.get('view.enable_asset_cache')) { return fs.readFileSync(path.resolve(this.publicAssetsPath + urlPath)); } const content = this.content.get(urlPath); if (!content) { throw new ServerError('Asset ' + path + ' was not loaded.'); } return content; }; next(); }); } private async forEachFileInDirRecursively(dir: string, consumer: (file: string) => Promise): Promise { await new Promise((resolve, reject) => { fs.readdir(dir, (err, files) => { if (err) return reject(err); resolve(Promise.all(files.map(file => new Promise((resolve, reject) => { file = path.join(dir, file); fs.stat(file, (err, stat) => { if (err) return reject(err); if (stat.isDirectory()) { resolve(this.forEachFileInDirRecursively(file, consumer)); } else { resolve(consumer(file)); } }); })))); }); }); } private async cacheFile(file: string): Promise { await new Promise((resolve, reject) => { fs.readFile(file, (err, data) => { if (err) return reject(data); const urlPath = file.replace(this.publicAssetsPath, ''); this.content.set(urlPath, data.toString()); logger.debug('Loaded', file, 'as', urlPath); resolve(); }); }); } private async preCompileSvelte(file: string): Promise { logger.debug('Pre-compiling', file); const source = await new Promise((resolve, reject) => { fs.readFile(file, (err, data) => { if (err) return reject(err); resolve(data.toString()); }); }); const {backendReplacedCode, backendLines} = this.replaceBackendCode(source); const preprocessed = await this.preprocessSvelte(backendReplacedCode, file); // Server Side Render (initial HTML, no-js) const ssr = this.compileSvelteSsr(preprocessed.code, file, preprocessed.sourcemap); // Actual svelte const svelte = this.compileSvelteJS(preprocessed.code, preprocessed.sourcemap); const separator = FrontendToolsComponent.getSveltePreCompileSeparator(file); const finalCode = [ [...backendLines.values()].join('\n'), ssr.head, ssr.html, ssr.css.code, ssr.css.map, svelte.code, svelte.map, ].join(separator); const newFile = path.join(this.svelteOutputPath, path.basename(file) + COMPILED_SVELTE_EXTENSION); await new Promise((resolve, reject) => fs.writeFile(newFile, finalCode, err => { if (err) return reject(err); resolve(); })); } private replaceBackendCode(code: string): { backendReplacedCode: string, backendLines: string[], } { if (code.indexOf(`export let swaf = {};`) < 0) { return { backendReplacedCode: code, backendLines: [], }; } const backendLines = new Set(); let index = 0; while ((index = code.indexOf(BACKEND_CODE_PREFIX, index + 1)) >= 0) { // Escaping if (index > 0 && code[index - 1] === '\\') { const isEscapingEscaped = index > 1 && code[index - 2] === '\\'; code = code.substring(0, index - 1 - (isEscapingEscaped ? 1 : 0)) + code.substring(index, code.length); continue; } const startIndex = index + BACKEND_CODE_PREFIX.length; let endIndex = startIndex; let struct = 0; while (endIndex < code.length) { if (['(', '[', '{'].indexOf(code[endIndex]) >= 0) struct++; if ([')', ']', '}'].indexOf(code[endIndex]) >= 0) { struct--; if (struct <= 0) { if (struct === 0) endIndex++; break; } } if ([' ', '\n', '<'].indexOf(code[endIndex]) >= 0 && struct === 0) break; endIndex++; } const backendLine = code.substring(startIndex, endIndex); backendLines.add(backendLine); code = code.substring(0, index) + 'swaf(`' + backendLine.replace(/([^\\])`/, '$1\\`') + '`)' + code.substring(endIndex, code.length); } logger.silly('Replaced backend code'); return { backendReplacedCode: code, backendLines: [...backendLines], }; } private async preprocessSvelte(code: string, filename: string): Promise<{ code: string, sourcemap?: SourceMap, }> { const preprocessed = await preprocess( code, sveltePreprocess({ typescript: { tsconfigFile: 'tsconfig.views.json', }, }), { filename: filename, }, ); return { code: preprocessed.code, sourcemap: preprocessed.map as (SourceMap | undefined), }; } private compileSvelteSsr(code: string, filename: string, sourcemap?: SourceMap): { head: string, css: CssResult, html: string, } { const svelteSsr = compile(code, { dev: false, generate: 'ssr', format: 'cjs', sourcemap: sourcemap, cssOutputFilename: filename + '.css', }); return requireFromString(svelteSsr.js.code, filename).default.render({ swaf: () => 'undefined', }); } private compileSvelteJS(code: string, sourcemap?: SourceMap): { code: string, map: SourceMap, } { const compiled = compile(code, { dev: false, hydratable: true, sourcemap: sourcemap, }); return { code: compiled.js.code, map: compiled.js.map, }; } } export type SourceMap = string | Record | undefined;