277 lines
9.2 KiB
TypeScript
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;
|