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"; import {compile, preprocess} from "svelte/compiler"; import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess.js"; import {publicUrl, routes} from "../common/Routing.js"; 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> = {}; private readonly preprocessingCache: Record = {}; private readonly cssCache: Record = {}; public constructor( targetDir: string, ...additionalViewPaths: string[] ) { super(targetDir, 'views', 'svelte', true, ...additionalViewPaths); } public async onFileChange(file: string): Promise { delete this.preprocessingCache[this.toCanonicalName(file)]; await super.onFileChange(file); } public async onFileRemove(file: string): Promise { const canonicalName = this.toCanonicalName(file); delete this.preprocessingCache[canonicalName]; delete this.dependencyCache[canonicalName]; Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName)); await super.onFileRemove(file); } public async render( file: string, locals: Record, ): Promise { 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('view.cache')); // Root template const templateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html'); const rawOutput = await this.fileCache.get(templateFile, !config.get('view.cache')); // Pre-compiled parts const [ backendCalls, head, html, css, ] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName)); const localMap: Record = 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; }); // Replaces const replaceMap: Record = { canonicalViewName: canonicalViewName, locals: actualLocals, head: head, html: html, css: css, routes: JSON.stringify(routes), publicUrl: publicUrl, }; return rawOutput.replace( new RegExp(Object.keys(replaceMap).map(str => `%${str}%`).join('|'), 'g'), (substring) => replaceMap[substring.slice(1, substring.length - 1)], ); } public async preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise { const targetFile = path.join(this.targetDir, canonicalName); logger.info(canonicalName + ' > ', 'Pre-compiling', canonicalName, '->', targetFile); const {backendCalls} = await this.preprocess(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(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); } } } 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(); } this.dependencyCache[dependency].add(canonicalViewName); } return dependencies; } private async preprocess(canonicalName: string): Promise { // Cache if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) { return this.preprocessingCache[canonicalName]; } const file = await this.resolveFileFromCanonicalNameOrFail(canonicalName); logger.info(canonicalName + ' > ', `Preprocessing ${file}`); // mkdir output file dir const outputFile = path.join(this.targetDir, canonicalName); await fs.mkdir(path.dirname(outputFile), {recursive: true}); // Read source file if code was not already provided const code = await this.fileCache.get(file, !config.get('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, sveltePreprocess({ typescript: { tsconfigFile: 'src/assets/views/tsconfig.json', }, }), { filename: outputFile, }, ); // Write to output file await fs.writeFile(outputFile, preprocessed.code); // Preprocess dependencies const backendCalls: string[] = replacedBackendCalls.backendCalls; for (const dependency of this.resolveDependencies(code, canonicalName)) { backendCalls.push(...(await this.preprocess(dependency)).backendCalls); } return this.preprocessingCache[canonicalName] = { backendCalls: backendCalls, code: preprocessed.code, }; } private async replaceBackendCalls(canonicalName: string, code: string): Promise { 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(); 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, css: string, html: string, }> { const targetFile = path.join(this.targetDir, canonicalName); const {backendCalls, code} = await this.preprocess(canonicalName); // Get dependencies css const dependenciesCss: string[] = []; for (const dependency of this.resolveDependencies(code, canonicalName)) { if (this.cssCache[dependency] === undefined) { await this.compileSsr(dependency); } const css = this.cssCache[dependency]; if (css === undefined) { logger.error(typeof this.cssCache[dependency], !!this.cssCache[dependency]); throw new Error(`Compiling ssr of ${dependency} didn't cache its css.`); } dependenciesCss.push(...css); } logger.info(canonicalName + ' > ', 'Compiling svelte ssr', targetFile); // Svelte compile const svelteSsr = compile(code, { dev: config.get('view.dev'), generate: 'ssr', format: 'cjs', cssOutputFilename: targetFile + '.css', }); // 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}'`]; }); // Load module and render const moduleId = path.resolve(targetFile); clearModule.single(moduleId); const { head, css, html, } = requireFromString(svelteSsr.js.code, moduleId).default.render(); const cssFragments = css.code === '' ? dependenciesCss : [...dependenciesCss, css.code]; this.cssCache[canonicalName] = cssFragments; return { head, css: [...new Set(cssFragments)].join(''), html, }; } private compileBackendCalls( backendCalls: string[], locals: Record, isPreRender: boolean, ): Record { locals = {...locals, isPreRender}; const localMap: Record = {}; 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 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; } } type PreprocessingCacheEntry = { backendCalls: string[], code: string, };