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.
@@ -1,89 +1,167 @@
1
- import fs from "node:fs/promises";
2
- import { frameworkError } from "../";
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
- function renderTemplate(
8
- templateStr: string,
9
- data: Record<string, unknown>
10
- ): string {
11
- // Initialize variables
12
- let result: (string | unknown)[] = [];
13
-
14
- let currentIndex = 0;
15
-
16
- while (currentIndex < templateStr.length) {
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, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#39;");
22
+ }
24
23
 
25
- // Push the part before the placeholder
26
- result.push(templateStr.slice(currentIndex, startIdx));
24
+ class TemplateTransform extends Transform {
25
+ private tail = "";
27
26
 
28
- // Find the closing placeholder
29
- const endIdx = templateStr.indexOf("}}", startIdx);
30
- if (endIdx === -1) {
31
- // No closing brace found, treat the rest as plain text
32
- result.push(templateStr.slice(startIdx));
33
- break;
34
- }
27
+ constructor(
28
+ private readonly data: Record<string, unknown>,
29
+ private readonly baseDir: string
30
+ ) {
31
+ super();
32
+ }
35
33
 
36
- // Extract the variable name
37
- const varName = templateStr.slice(startIdx + 2, endIdx).trim();
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
- // Replace the variable with its value from the data, or use an empty string if not found
40
- const replacement = data[varName] !== undefined ? data[varName] : "";
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
- // Push the replacement to the result array
43
- result.push(replacement);
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
- // Move the index past the current closing placeholder
46
- currentIndex = endIdx + 2;
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
- // Join all parts into a final string
50
- return result.join("");
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
- path: string,
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 = path.lastIndexOf(".");
67
- const fileExtension = dotIndex >= 0 ? path.slice(dotIndex + 1) : "";
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 "${path}". Pass it as the third argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
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
- let fileStr = await fs.readFile(path, "utf-8");
78
- const finalStr = renderTemplate(fileStr, data);
139
+ const resolved = path.resolve(filePath);
79
140
 
80
- if (res._compression) {
81
- await compressAndSend(res, mime, finalStr, res._compression);
82
- return;
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
- res.setHeader("Content-Type", mime);
86
- res.end(finalStr);
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();
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cpeak",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "A minimal and fast Node.js HTTP framework.",
5
5
  "type": "module",
6
6
  "scripts": {