swaf/src/components/FrontendToolsComponent.ts

277 lines
9.2 KiB
TypeScript

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<string, string> = 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<void> {
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<void> {
if (config.get<boolean>('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<void> {
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<void> {
router.use((req, res, next) => {
res.locals.inlineAsset = (urlPath: string) => {
if (!config.get<boolean>('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<void>): Promise<void> {
await new Promise<void[]>((resolve, reject) => {
fs.readdir(dir, (err, files) => {
if (err) return reject(err);
resolve(Promise.all<void>(files.map(file => new Promise<void>((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<void> {
await new Promise<void>((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<void> {
logger.debug('Pre-compiling', file);
const source = await new Promise<string>((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<void>((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<string>();
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<string, unknown> | undefined;