import crypto from "crypto"; import path from "path"; import {PreRenderedChunk, rollup, RollupBuild, RollupOptions, RollupWatcher, RollupWatchOptions, watch} from "rollup"; import config from "config"; import ViewEngine from "./ViewEngine"; import {logger} from "../Logger"; import FileCache from "../utils/FileCache"; import {afs} from "../Utils"; import {compile, preprocess} from "svelte/compiler"; import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess"; import requireFromString from "require-from-string"; import {CssResult} from "svelte/types/compiler/interfaces"; import svelte from "rollup-plugin-svelte"; import cssOnlyRollupPlugin from "rollup-plugin-css-only"; import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import {terser} from "rollup-plugin-terser"; import livereloadRollupPlugin from "rollup-plugin-livereload"; const BACKEND_CODE_PREFIX = 'swaf.'; const COMPILED_SVELTE_EXTENSION = '.swafview'; 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 backendCodeCache: Record = {}; private rollup?: RollupBuild | RollupWatcher; public constructor( buildDir: string, publicDir: string, devWatchedViewPath: string, ...additionalViewPaths: string[] ) { super(buildDir, publicDir, devWatchedViewPath, ...additionalViewPaths); } public getExtension(): string { return 'svelte'; } /** * TODO: add replaces on ssr html */ public async render( file: string, locals: Record, callback: (err: Error | null, output?: string) => void, ): Promise { const canonicalViewName = this.toCanonicalName(file); // View const actualFile = path.join(this.getBuildDir(), canonicalViewName + COMPILED_SVELTE_EXTENSION); const view = await this.fileCache.get(actualFile, config.get('view.dev')); // Root template const templateFile = await this.resolveFileFromCanonicalName('layouts/svelte_layout.html'); let output = await this.fileCache.get(templateFile, config.get('view.dev')); // Pre-compiled parts const [ backendLines, head, html, css, ] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName)); // Props (locals) locals = Object.assign(this.getGlobals(), locals); const localMap: Record = {}; backendLines.split('\n').forEach(line => { const key = line.substring(1, line.indexOf(',') >= 0 ? line.indexOf(',') - 1 : line.length - 1); if (line.indexOf('[') >= 0) { const args = line.substring(line.indexOf('[') + 1, line.length - 1) .split(/, *?/) .map(arg => { if (arg.startsWith("'")) return '"' + arg.substring(1, arg.length - 1) + '"'; return arg; }) .map(arg => JSON.parse(arg)); const f = locals[key]; if (typeof f !== 'function') throw new Error(key + ' is not a function.'); localMap[`'${key}', ${JSON.stringify(args)}`] = f.call(locals, ...args); } else { localMap[`'${key}'`] = locals[key]; } }); const props = JSON.stringify(localMap); // Replaces output = output.replace('%canonicalViewName%', canonicalViewName); output = output.replace('%props%', props); output = output.replace('%head%', head); output = output.replace('%html%', html); output = output.replace('%css%', css); callback(null, output); } public async end(): Promise { await this.stopRollup(); } /** * TODO: add a way to add globals from anywhere */ private getGlobals(): Record { return { route: (name: string) => 'unimplemented route ' + name, direct: 'access', }; } public async preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise { const file = await this.resolveFileFromCanonicalName(canonicalName); const intermediateFile = path.join(this.getBuildDir(), canonicalName); logger.debug(canonicalName + ' > ', 'Pre-compiling', file, '->', intermediateFile); const source = await this.fileCache.get(file, config.get('view.dev')); const allBackendLines: string[] = []; for (const dependency of this.resolveDependencies(source, canonicalName)) { allBackendLines.push(...(await this.replaceBackendCode(dependency)).backendLines); } const {backendReplacedCode, backendLines} = await this.replaceBackendCode(canonicalName, source); allBackendLines.push(...backendLines); // Server Side Render (initial HTML and CSS, no-js) const ssr = await this.compileSsr(canonicalName, intermediateFile, backendReplacedCode); const separator = SvelteViewEngine.getPreCompileSeparator(canonicalName); const finalCode = [ [...new Set(allBackendLines).values()].join('\n'), ssr.head, ssr.html, ssr.css.code, ].join(separator); const swafViewFile = path.join(this.getBuildDir(), canonicalName + COMPILED_SVELTE_EXTENSION); await afs.mkdir(path.dirname(swafViewFile), {recursive: true}); await afs.writeFile(swafViewFile, finalCode); if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) { logger.debug(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 replaceBackendCode(canonicalViewName: string, code?: string): Promise<{ backendReplacedCode: string, backendLines: string[], }> { // Cache if (Object.keys(this.backendCodeCache).indexOf(canonicalViewName) >= 0) { return this.backendCodeCache[canonicalViewName]; } // mkdir output file dir const outputFile = path.join(this.getBuildDir(), canonicalViewName); await afs.mkdir(path.dirname(outputFile), {recursive: true}); // Read source file if code was not already provided if (!code) { const file = await this.resolveFileFromCanonicalName(canonicalViewName); code = await this.fileCache.get(file, config.get('view.dev')); } // Skip replace if there is no swaf export if (!code.match(/export[ \n]+let[ \n]+swaf[ \n]*=[ \n]*{[ \n]*}/)) { const generated = { backendReplacedCode: code, backendLines: [], }; await afs.writeFile(outputFile, generated.backendReplacedCode); this.backendCodeCache[canonicalViewName] = generated; return generated; } let output = code; const backendLines = 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 backendLine = output.substring(startIndex, endIndex); if (backendLine.match(/([^()]+)\((.+?)\)/)) { backendLine = backendLine.replace(/([^()]+)\((.+?)\)/, "'$1', [$2]"); } else { backendLine = backendLine.replace(/([^()]+)/, "'$1'"); } backendLines.add(backendLine); output = output.substring(0, index) + 'swaf(' + backendLine + ')' + output.substring(endIndex, output.length); } const generated = { backendReplacedCode: output, backendLines: [...backendLines], }; await afs.writeFile(outputFile, generated.backendReplacedCode); this.backendCodeCache[canonicalViewName] = generated; return generated; } public async afterPreCompile(): Promise { await this.bundle(...Object.keys(this.backendCodeCache)); } public async onNewFile(): Promise { await this.stopRollup(); } public async onFileChange(file: string): Promise { delete this.backendCodeCache[this.toCanonicalName(file)]; } private async compileSsr(canonicalName: string, file: string, code: string): Promise<{ head: string, css: CssResult, html: string, }> { // Svelte preprocess logger.debug(canonicalName + ' > ', 'Preprocessing svelte', file); const preprocessed = await preprocess( code, sveltePreprocess({ typescript: { tsconfigFile: 'tsconfig.views.json', }, }), { filename: file, }, ); // Svelte compile logger.debug(canonicalName + ' > ', 'Compiling svelte ssr', file); const svelteSsr = compile(preprocessed.code, { dev: config.get('view.dev'), generate: 'ssr', format: 'cjs', cssOutputFilename: file + '.css', }); const locals = this.getGlobals(); return requireFromString(svelteSsr.js.code, file).default.render({ swaf: (key: string, args?: unknown[]) => { if (!args) return locals[key]; const f = locals[key]; if (typeof f !== 'function') throw new Error(key + ' is not a function.'); return f.call(locals, ...args); }, }); } /** * TODO: load rollup config from external source */ private async bundle(...canonicalViewNames: string[]): Promise { logger.debug('Bundling'); const production = !config.get('view.dev'); const options: RollupOptions | RollupWatchOptions = { input: canonicalViewNames.map(name => path.join(this.getBuildDir(), name)), output: { sourcemap: true, format: 'es', name: 'bundle', dir: path.join(this.getPublicDir(), 'js'), entryFileNames: (chunkInfo: PreRenderedChunk): string => { const name = chunkInfo.facadeModuleId ? path.relative(this.getBuildDir(), chunkInfo.facadeModuleId) : chunkInfo.name; return name + '.js'; }, }, plugins: [ svelte({ preprocess: sveltePreprocess({ typescript: { tsconfigFile: 'tsconfig.views.json', }, }), compilerOptions: { // enable run-time checks when not in production dev: !production, hydratable: true, }, }), // we'll extract any component CSS out into // a separate file - better for performance cssOnlyRollupPlugin({output: 'bundle.css'}), // If you have external dependencies installed from // npm, you'll most likely need these plugins. In // some cases you'll need additional configuration - // consult the documentation for details: // https://github.com/rollup/plugins/tree/master/packages/commonjs resolve({ browser: true, dedupe: ['svelte'], }), commonjs(), ], watch: { buildDelay: 1000, clearScreen: false, }, }; if (production) { // If we're building for production (npm run build // instead of npm run dev), minify options.plugins?.push(terser()); } else { // Watch the `public` directory and refresh the // browser on changes when not in production const plugin = livereloadRollupPlugin('public'); options.plugins?.push(plugin); } for (const name of canonicalViewNames) { await afs.mkdir(path.dirname(path.join(this.getPublicDir(), 'js', name)), {recursive: true}); } if (production) { if (!this.rollup) { this.rollup = await rollup(options); await this.rollup.write({ format: 'es', dir: path.join(this.getPublicDir(), 'js'), }); } } else { if (!this.rollup) { this.rollup = watch(options); this.rollup.on('event', (event) => { if (event.code === 'ERROR' || event.code === 'BUNDLE_END') { event.result?.close().catch(err => logger.error(err)); logger.debug('Bundled from watch'); } }); } } } private async stopRollup(): Promise { if (this.rollup) { logger.debug('Stopping rollup...'); await this.rollup.close(); this.rollup = undefined; } } }