cpeak 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +54 -45
- package/dist/index.js +207 -69
- package/dist/index.js.map +1 -1
- package/lib/index.ts +26 -5
- package/lib/internal/errors.ts +18 -2
- package/lib/internal/mimeTypes.ts +10 -1
- package/lib/types.ts +14 -8
- package/lib/utils/render.ts +134 -56
- package/lib/utils/serveStatic.ts +8 -1
- package/package.json +1 -1
package/lib/utils/render.ts
CHANGED
|
@@ -1,89 +1,167 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { Transform } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import type { TransformCallback } from "node:stream";
|
|
7
|
+
import { frameworkError, ErrorCode } from "../";
|
|
8
|
+
import { isClientDisconnect } from "../internal/errors";
|
|
3
9
|
import { compressAndSend } from "../internal/compression";
|
|
4
10
|
import { MIME_TYPES } from "../internal/mimeTypes";
|
|
5
11
|
import type { CpeakRequest, CpeakResponse, Next } from "../types";
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// Find the next opening placeholder
|
|
18
|
-
const startIdx = templateStr.indexOf("{{", currentIndex);
|
|
19
|
-
if (startIdx === -1) {
|
|
20
|
-
// No more placeholders, push the remaining string
|
|
21
|
-
result.push(templateStr.slice(currentIndex));
|
|
22
|
-
break;
|
|
23
|
-
}
|
|
13
|
+
export const MAX_PATTERN = 128;
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, """)
|
|
21
|
+
.replace(/'/g, "'");
|
|
22
|
+
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
class TemplateTransform extends Transform {
|
|
25
|
+
private tail = "";
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly data: Record<string, unknown>,
|
|
29
|
+
private readonly baseDir: string
|
|
30
|
+
) {
|
|
31
|
+
super();
|
|
32
|
+
}
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
_transform(
|
|
35
|
+
chunk: Buffer,
|
|
36
|
+
_: BufferEncoding,
|
|
37
|
+
callback: TransformCallback
|
|
38
|
+
): void {
|
|
39
|
+
const str = this.tail + chunk.toString("utf8");
|
|
40
|
+
if (str.length <= MAX_PATTERN) {
|
|
41
|
+
this.tail = str;
|
|
42
|
+
callback();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
let boundary = str.length - MAX_PATTERN;
|
|
47
|
+
|
|
48
|
+
// Prevent cutting a tag in two
|
|
49
|
+
for (const [opener, closer] of [
|
|
50
|
+
["{{", "}}"],
|
|
51
|
+
["<cpeak", ">"]
|
|
52
|
+
]) {
|
|
53
|
+
const last = str.lastIndexOf(opener, boundary - 1);
|
|
54
|
+
if (last === -1) continue;
|
|
55
|
+
const closeIdx = str.indexOf(closer, last + opener.length);
|
|
56
|
+
if (closeIdx === -1 || closeIdx >= boundary)
|
|
57
|
+
boundary = Math.min(boundary, last);
|
|
58
|
+
}
|
|
41
59
|
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
this.tail = str.slice(boundary);
|
|
61
|
+
const safe = str.slice(0, boundary);
|
|
62
|
+
if (safe)
|
|
63
|
+
this.process(safe)
|
|
64
|
+
.then(() => callback())
|
|
65
|
+
.catch(callback);
|
|
66
|
+
else callback();
|
|
67
|
+
}
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
_flush(callback: TransformCallback): void {
|
|
70
|
+
if (this.tail)
|
|
71
|
+
this.process(this.tail)
|
|
72
|
+
.then(() => callback())
|
|
73
|
+
.catch(callback);
|
|
74
|
+
else callback();
|
|
47
75
|
}
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
|
|
77
|
+
private async process(str: string): Promise<void> {
|
|
78
|
+
const RE =
|
|
79
|
+
/<cpeak\s+include="([^"]+)"\s*\/?>|<cpeak\s+html=\{([^}]+)\}\s*\/?>|\{\{([^}]+)\}\}/g;
|
|
80
|
+
let last = 0;
|
|
81
|
+
|
|
82
|
+
for (const match of str.matchAll(RE)) {
|
|
83
|
+
const idx = match.index!;
|
|
84
|
+
if (idx > last) this.push(str.slice(last, idx));
|
|
85
|
+
|
|
86
|
+
const [, includeSrc, rawKey, escapedKey] = match;
|
|
87
|
+
|
|
88
|
+
if (includeSrc !== undefined) {
|
|
89
|
+
const includePath = path.resolve(this.baseDir, includeSrc);
|
|
90
|
+
const content = await readFile(includePath, "utf8");
|
|
91
|
+
const chunks: Buffer[] = [];
|
|
92
|
+
const nested = new TemplateTransform(
|
|
93
|
+
this.data,
|
|
94
|
+
path.dirname(includePath)
|
|
95
|
+
);
|
|
96
|
+
await new Promise<void>((resolve, reject) => {
|
|
97
|
+
nested.on("data", (c: Buffer) => chunks.push(c));
|
|
98
|
+
nested.on("end", resolve);
|
|
99
|
+
nested.on("error", reject);
|
|
100
|
+
nested.end(Buffer.from(content, "utf8"));
|
|
101
|
+
});
|
|
102
|
+
this.push(Buffer.concat(chunks));
|
|
103
|
+
} else if (rawKey !== undefined) {
|
|
104
|
+
const val = this.data[rawKey.trim()];
|
|
105
|
+
if (val !== undefined) this.push(String(val));
|
|
106
|
+
} else {
|
|
107
|
+
const val = this.data[escapedKey.trim()];
|
|
108
|
+
if (val !== undefined) this.push(escapeHtml(String(val)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
last = idx + match[0].length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (last < str.length) this.push(str.slice(last));
|
|
115
|
+
}
|
|
51
116
|
}
|
|
52
117
|
|
|
53
|
-
// Errors to return: recommend to not render files larger than 100KB
|
|
54
|
-
// To Explore: Doing the operation in C++ and return the data as stream back to the client
|
|
55
|
-
// @TODO: remove the file from static map
|
|
56
|
-
// @TODO: escape the string to prevent XSS
|
|
57
|
-
// @TODO: add another {{{ }}} option to not escape the string
|
|
58
118
|
const render = () => {
|
|
59
119
|
return function (req: CpeakRequest, res: CpeakResponse, next: Next): void {
|
|
60
120
|
res.render = async (
|
|
61
|
-
|
|
121
|
+
filePath: string,
|
|
62
122
|
data: Record<string, unknown>,
|
|
63
123
|
mime?: string
|
|
64
124
|
) => {
|
|
125
|
+
if (res.headersSent) return;
|
|
65
126
|
if (!mime) {
|
|
66
|
-
const dotIndex =
|
|
67
|
-
const fileExtension = dotIndex >= 0 ?
|
|
127
|
+
const dotIndex = filePath.lastIndexOf(".");
|
|
128
|
+
const fileExtension = dotIndex >= 0 ? filePath.slice(dotIndex + 1) : "";
|
|
68
129
|
mime = MIME_TYPES[fileExtension];
|
|
69
130
|
if (!mime) {
|
|
70
131
|
throw frameworkError(
|
|
71
|
-
`MIME type is missing for "${
|
|
72
|
-
res.render
|
|
132
|
+
`MIME type is missing for "${filePath}". Pass it as the third argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
|
|
133
|
+
res.render,
|
|
134
|
+
ErrorCode.MISSING_MIME
|
|
73
135
|
);
|
|
74
136
|
}
|
|
75
137
|
}
|
|
76
138
|
|
|
77
|
-
|
|
78
|
-
const finalStr = renderTemplate(fileStr, data);
|
|
139
|
+
const resolved = path.resolve(filePath);
|
|
79
140
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
141
|
+
try {
|
|
142
|
+
if (res._compression) {
|
|
143
|
+
const readStream = createReadStream(resolved);
|
|
144
|
+
const transform = new TemplateTransform(data, path.dirname(resolved));
|
|
145
|
+
pipeline(readStream, transform).catch(() => {});
|
|
146
|
+
await compressAndSend(res, mime, transform, res._compression);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
84
149
|
|
|
85
|
-
|
|
86
|
-
|
|
150
|
+
res.setHeader("Content-Type", mime);
|
|
151
|
+
await pipeline(
|
|
152
|
+
createReadStream(resolved),
|
|
153
|
+
new TemplateTransform(data, path.dirname(resolved)),
|
|
154
|
+
res
|
|
155
|
+
);
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
throw frameworkError(
|
|
158
|
+
`Failed to render "${filePath}." Error: ${err as Error}`,
|
|
159
|
+
res.render,
|
|
160
|
+
ErrorCode.RENDER_FAIL,
|
|
161
|
+
undefined,
|
|
162
|
+
isClientDisconnect(err)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
87
165
|
};
|
|
88
166
|
|
|
89
167
|
next();
|
package/lib/utils/serveStatic.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { CpeakRequest, CpeakResponse, Next } from "../types";
|
|
|
6
6
|
|
|
7
7
|
const serveStatic = (
|
|
8
8
|
folderPath: string,
|
|
9
|
-
options?: { prefix?: string; live?: boolean }
|
|
9
|
+
options?: { prefix?: string; live?: boolean; exclude?: string[] }
|
|
10
10
|
) => {
|
|
11
11
|
const prefix = options?.prefix ?? "";
|
|
12
12
|
const live = options?.live ?? false;
|
|
@@ -16,6 +16,7 @@ const serveStatic = (
|
|
|
16
16
|
// If file names dynamically change often in production, then live option can be set to true to process the folder on every request, but it may have performance implications.
|
|
17
17
|
if (live) {
|
|
18
18
|
const resolvedFolder = path.resolve(folderPath);
|
|
19
|
+
const excludes = (options?.exclude ?? []).map(e => path.join(resolvedFolder, e));
|
|
19
20
|
|
|
20
21
|
return async function (req: CpeakRequest, res: CpeakResponse, next: Next) {
|
|
21
22
|
const url = req.url;
|
|
@@ -28,6 +29,7 @@ const serveStatic = (
|
|
|
28
29
|
const mime = MIME_TYPES[fileExtension];
|
|
29
30
|
|
|
30
31
|
if (!mime || !filePath.startsWith(resolvedFolder)) return next();
|
|
32
|
+
if (excludes.some(e => filePath.startsWith(e))) return next();
|
|
31
33
|
|
|
32
34
|
const stat = await fs.promises.stat(filePath).catch(() => null);
|
|
33
35
|
if (stat?.isFile()) return res.sendFile(filePath, mime);
|
|
@@ -36,6 +38,9 @@ const serveStatic = (
|
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
const resolvedFolder = path.resolve(folderPath);
|
|
42
|
+
const excludes = (options?.exclude ?? []).map(e => path.join(resolvedFolder, e));
|
|
43
|
+
|
|
39
44
|
function processFolder(folderPath: string, parentFolder: string) {
|
|
40
45
|
const staticFiles: string[] = [];
|
|
41
46
|
|
|
@@ -48,10 +53,12 @@ const serveStatic = (
|
|
|
48
53
|
|
|
49
54
|
// Check if it's a directory
|
|
50
55
|
if (fs.statSync(fullPath).isDirectory()) {
|
|
56
|
+
if (excludes.some(e => fullPath.startsWith(e))) continue;
|
|
51
57
|
// If it's a directory, recursively process it
|
|
52
58
|
const subfolderFiles = processFolder(fullPath, parentFolder);
|
|
53
59
|
staticFiles.push(...subfolderFiles);
|
|
54
60
|
} else {
|
|
61
|
+
if (excludes.some(e => fullPath.startsWith(e))) continue;
|
|
55
62
|
// If it's a file, add it to the array
|
|
56
63
|
const relativePath = path.relative(parentFolder, fullPath);
|
|
57
64
|
const fileExtension = path.extname(file).slice(1);
|