cpeak 2.7.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.
@@ -0,0 +1,259 @@
1
+ import type { Handler, RouteMiddleware, StringMap } from "../types";
2
+ import { frameworkError, ErrorCode } from "./errors";
3
+
4
+ // A node in our radix tree. Each one can hold up to three kinds of children:
5
+ // an exact static segment, a single ":param" placeholder, or a tail "*"
6
+ // wildcard. The handler and middleware here belong to the route whose path
7
+ // ends at this node, if any.
8
+ //
9
+ // Param names are not stored on the tree edges. We capture values positionally
10
+ // as we walk, and zip them with the param names attached to whichever leaf we
11
+ // land on. That lets two routes share the same param slot in the tree even
12
+ // when they use different names, like "/:id/profile" and "/:username/settings".
13
+ interface RadixNode {
14
+ staticChildren: Map<string, RadixNode>;
15
+ paramChild?: RadixNode;
16
+ wildcardChild?: WildcardLeaf;
17
+ handler?: Handler;
18
+ middleware?: RouteMiddleware[];
19
+ // Names of params captured along the path to this leaf, in order. Only set
20
+ // on nodes that own a handler.
21
+ paramNames?: string[];
22
+ }
23
+
24
+ interface WildcardLeaf {
25
+ handler: Handler;
26
+ middleware: RouteMiddleware[];
27
+ // Names of params captured before reaching this wildcard, in order.
28
+ paramNames: string[];
29
+ }
30
+
31
+ export interface RouteMatch {
32
+ middleware: RouteMiddleware[];
33
+ handler: Handler;
34
+ params: StringMap;
35
+ }
36
+
37
+ function createNode(): RadixNode {
38
+ return { staticChildren: new Map() };
39
+ }
40
+
41
+ // We keep one radix tree per HTTP method so different methods can safely
42
+ // share a path shape. POST /comments/:pageId and PUT /comments/:id can
43
+ // coexist without conflict because they live in separate trees.
44
+ export class Router {
45
+ #treesByMethod: Map<string, RadixNode> = new Map();
46
+
47
+ add(
48
+ method: string,
49
+ path: string,
50
+ middleware: RouteMiddleware[],
51
+ handler: Handler
52
+ ) {
53
+ const methodKey = method.toLowerCase();
54
+ let root = this.#treesByMethod.get(methodKey);
55
+ if (!root) {
56
+ root = createNode();
57
+ this.#treesByMethod.set(methodKey, root);
58
+ }
59
+
60
+ const segments = splitPath(path);
61
+ const paramNames: string[] = [];
62
+ let currentNode = root;
63
+
64
+ for (let i = 0; i < segments.length; i++) {
65
+ const segment = segments[i];
66
+ const isLastSegment = i === segments.length - 1;
67
+
68
+ // Named wildcards like "*name" are not a thing here. Only a bare "*"
69
+ // is allowed, and only as the very last segment.
70
+ if (segment.length > 1 && segment.startsWith("*")) {
71
+ throw frameworkError(
72
+ `Invalid route "${path}": named wildcards (e.g. "*name") are not supported. Use a plain "*" at the end of the path.`,
73
+ this.add,
74
+ ErrorCode.INVALID_ROUTE
75
+ );
76
+ }
77
+
78
+ // A "*" segment installs a tail wildcard on the current node. After
79
+ // that there's nothing more to walk, so we register and bail out.
80
+ if (segment === "*") {
81
+ if (!isLastSegment) {
82
+ throw frameworkError(
83
+ `Invalid route "${path}": "*" is only allowed as the final path segment.`,
84
+ this.add,
85
+ ErrorCode.INVALID_ROUTE
86
+ );
87
+ }
88
+ if (currentNode.wildcardChild) {
89
+ throw frameworkError(
90
+ `Duplicate route: ${method.toUpperCase()} ${path}`,
91
+ this.add,
92
+ ErrorCode.DUPLICATE_ROUTE
93
+ );
94
+ }
95
+ currentNode.wildcardChild = { handler, middleware, paramNames };
96
+ return;
97
+ }
98
+
99
+ // A ":name" segment walks into the param branch at this depth, or
100
+ // creates one. The name is collected positionally and resolved later
101
+ // at the leaf, so two routes can disagree on the param name here as
102
+ // long as their paths diverge before the leaf.
103
+ if (segment.startsWith(":")) {
104
+ const paramName = segment.slice(1);
105
+ if (!paramName) {
106
+ throw frameworkError(
107
+ `Invalid route "${path}": empty parameter name.`,
108
+ this.add,
109
+ ErrorCode.INVALID_ROUTE
110
+ );
111
+ }
112
+ paramNames.push(paramName);
113
+ if (!currentNode.paramChild) {
114
+ currentNode.paramChild = createNode();
115
+ }
116
+ currentNode = currentNode.paramChild;
117
+ continue;
118
+ }
119
+
120
+ // Plain static segment. Walk into the existing child or create a new one.
121
+ let staticChild = currentNode.staticChildren.get(segment);
122
+ if (!staticChild) {
123
+ staticChild = createNode();
124
+ currentNode.staticChildren.set(segment, staticChild);
125
+ }
126
+ currentNode = staticChild;
127
+ }
128
+
129
+ // We have consumed every segment of the path. The terminal node is where
130
+ // the handler gets attached. If something is already attached here, the
131
+ // user registered this exact path twice.
132
+ if (currentNode.handler) {
133
+ throw frameworkError(
134
+ `Duplicate route: ${method.toUpperCase()} ${path}`,
135
+ this.add,
136
+ ErrorCode.DUPLICATE_ROUTE
137
+ );
138
+ }
139
+ currentNode.handler = handler;
140
+ currentNode.middleware = middleware;
141
+ currentNode.paramNames = paramNames;
142
+ }
143
+
144
+ find(method: string, path: string): RouteMatch | null {
145
+ const root = this.#treesByMethod.get(method.toLowerCase());
146
+ if (!root) return null;
147
+
148
+ const segments = splitPath(path);
149
+ return matchSegments(root, segments, 0, []);
150
+ }
151
+ }
152
+
153
+ // Walk the tree one segment at a time, always trying static before param
154
+ // before wildcard. That ordering is where our precedence rules come from:
155
+ // static beats param beats wildcard. Because each branch is tried in turn
156
+ // and recursion lets us unwind a failed path, the matcher also backtracks.
157
+ // If the static branch dead-ends deeper down, we come back up and try the
158
+ // param sibling with the same segment value.
159
+ //
160
+ // We collect captured param values positionally as we walk. The actual names
161
+ // get zipped in at the terminal leaf, using the paramNames stored alongside
162
+ // the handler. That way the same captured value can be called "id" on one
163
+ // route and "username" on another without the tree caring.
164
+ function matchSegments(
165
+ node: RadixNode,
166
+ segments: string[],
167
+ segmentIndex: number,
168
+ capturedValues: string[]
169
+ ): RouteMatch | null {
170
+ // Out of segments to walk. If this node has a handler, that's our match.
171
+ // Otherwise let a wildcard at this depth catch the empty remainder so
172
+ // routes like "/foo/*" still match a request to "/foo".
173
+ if (segmentIndex === segments.length) {
174
+ if (node.handler) {
175
+ return {
176
+ middleware: node.middleware!,
177
+ handler: node.handler,
178
+ params: zipParams(node.paramNames!, capturedValues)
179
+ };
180
+ }
181
+ if (node.wildcardChild) {
182
+ return {
183
+ middleware: node.wildcardChild.middleware,
184
+ handler: node.wildcardChild.handler,
185
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
186
+ };
187
+ }
188
+ return null;
189
+ }
190
+
191
+ const segment = segments[segmentIndex];
192
+
193
+ // Try the exact static child first. Exact matches always win.
194
+ const staticChild = node.staticChildren.get(segment);
195
+ if (staticChild) {
196
+ const foundMatch = matchSegments(
197
+ staticChild,
198
+ segments,
199
+ segmentIndex + 1,
200
+ capturedValues
201
+ );
202
+ if (foundMatch) return foundMatch;
203
+ }
204
+
205
+ // Then try the param branch. We push the captured value before recursing
206
+ // and pop it back off if the recursion fails, so any sibling branch (or the
207
+ // caller unwinding above us) sees a clean capture list.
208
+ if (node.paramChild) {
209
+ capturedValues.push(safeDecode(segment));
210
+ const foundMatch = matchSegments(
211
+ node.paramChild,
212
+ segments,
213
+ segmentIndex + 1,
214
+ capturedValues
215
+ );
216
+ if (foundMatch) return foundMatch;
217
+ capturedValues.pop();
218
+ }
219
+
220
+ // Last resort. A wildcard at this node swallows whatever segments remain.
221
+ if (node.wildcardChild) {
222
+ return {
223
+ middleware: node.wildcardChild.middleware,
224
+ handler: node.wildcardChild.handler,
225
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
226
+ };
227
+ }
228
+
229
+ return null;
230
+ }
231
+
232
+ function zipParams(names: string[], values: string[]): StringMap {
233
+ const params: StringMap = {};
234
+ for (let i = 0; i < names.length; i++) {
235
+ params[names[i]] = values[i];
236
+ }
237
+ return params;
238
+ }
239
+
240
+ // Decode a URL segment without ever throwing. Malformed percent encoding is
241
+ // rare but it does happen in the wild. Falling back to the raw segment keeps
242
+ // the request matchable instead of blowing up before the handler runs.
243
+ // Example: safeDecode("a%20b%2Fc") returns "a b/c", while safeDecode("a%ZZb") returns "a%ZZb".
244
+ function safeDecode(segment: string): string {
245
+ try {
246
+ return decodeURIComponent(segment);
247
+ } catch {
248
+ return segment;
249
+ }
250
+ }
251
+
252
+ // Split a URL path into segments with no leading slash. We treat "" and "/"
253
+ // the same way: zero segments, meaning the root of the tree.
254
+ // Example: "/a/b/c" becomes ["a", "b", "c"]
255
+ function splitPath(path: string): string[] {
256
+ if (path === "" || path === "/") return [];
257
+ const withoutLeadingSlash = path.startsWith("/") ? path.slice(1) : path;
258
+ return withoutLeadingSlash.split("/");
259
+ }
package/lib/types.ts CHANGED
@@ -1,13 +1,21 @@
1
- import { IncomingMessage, ServerResponse } from "node:http";
1
+ import { IncomingMessage, ServerResponse, type Server } from "node:http";
2
2
  import type { Readable } from "node:stream";
3
3
  import type { Buffer } from "node:buffer";
4
4
  import type { CompressionOptions } from "./internal/types";
5
+ import type { CookieOptions } from "./utils/types";
6
+ import type { CpeakIncomingMessage, CpeakServerResponse } from "./index";
5
7
 
6
8
  export type { Cpeak } from "./index";
7
9
 
10
+ export type CpeakHttpServer = Server<
11
+ typeof CpeakIncomingMessage,
12
+ typeof CpeakServerResponse
13
+ >;
14
+
8
15
  // For constructor options passed to `cpeak()`
9
16
  export interface CpeakOptions {
10
17
  compression?: boolean | CompressionOptions;
18
+ mimeTypes?: StringMap;
11
19
  }
12
20
 
13
21
  // Extending Node.js's Request and Response objects to add our custom properties
@@ -25,55 +33,51 @@ export interface CpeakRequest<
25
33
  [key: string]: any; // allow developers to add their onw extensions (e.g. req.test)
26
34
  }
27
35
 
28
- export interface CpeakResponse extends ServerResponse {
29
- sendFile: (path: string, mime: string) => Promise<void>;
30
- status: (code: number) => CpeakResponse;
31
- attachment: (filename?: string) => CpeakResponse;
32
- cookie: (name: string, value: string, options?: any) => CpeakResponse;
36
+ export interface CpeakResponse<ResBody = any> extends ServerResponse {
37
+ sendFile: (path: string, mime?: string) => Promise<void>;
38
+ status: (code: number) => CpeakResponse<ResBody>;
39
+ attachment: (filename?: string) => CpeakResponse<ResBody>;
40
+ cookie: (name: string, value: string, options?: CookieOptions) => CpeakResponse<ResBody>;
33
41
  redirect: (location: string) => void;
34
- json: (data: any) => void | Promise<void>; // sync when compression is off, async when enabled
42
+ json: (data: ResBody) => Promise<void>;
35
43
  compress: (
36
44
  mime: string,
37
45
  body: Buffer | string | Readable,
38
46
  size?: number
39
47
  ) => Promise<void>;
48
+ render: (
49
+ filePath: string,
50
+ data: Record<string, unknown>,
51
+ mime?: string
52
+ ) => Promise<void>;
40
53
  [key: string]: any; // allow developers to add their onw extensions (e.g. res.test)
41
54
  }
42
55
 
43
56
  export type Next = (err?: any) => void;
44
- export type HandleErr = (err: any) => void;
45
57
 
46
58
  // beforeEach middleware: (req, res, next)
47
59
  export type Middleware<ReqBody = any, ReqParams = any> = (
48
60
  req: CpeakRequest<ReqBody, ReqParams>,
49
61
  res: CpeakResponse,
50
62
  next: Next
51
- ) => void;
63
+ ) => unknown;
52
64
 
53
- // Route middleware: (req, res, next, handleErr)
65
+ // Route middleware: (req, res, next)
54
66
  export type RouteMiddleware<ReqBody = any, ReqParams = any> = (
55
67
  req: CpeakRequest<ReqBody, ReqParams>,
56
68
  res: CpeakResponse,
57
- next: Next,
58
- handleErr: HandleErr
59
- ) => void | Promise<void>;
69
+ next: Next
70
+ ) => unknown;
60
71
 
61
- // Route handlers: (req, res, handleErr)
62
- export type Handler<ReqBody = any, ReqParams = any> = (
72
+ // Route handlers: (req, res)
73
+ export type Handler<ReqBody = any, ReqParams = any, ResBody = any> = (
63
74
  req: CpeakRequest<ReqBody, ReqParams>,
64
- res: CpeakResponse,
65
- handleErr: HandleErr
66
- ) => void | Promise<void>;
75
+ res: CpeakResponse<ResBody>
76
+ ) => unknown;
67
77
 
68
- // For a route object value in Cpeak.routes. The key is the method name.
78
+ // Represents a single registered route.
69
79
  export interface Route {
70
80
  path: string;
71
- regex: RegExp;
72
81
  middleware: RouteMiddleware[];
73
82
  cb: Handler;
74
83
  }
75
-
76
- // For Cpeak.routes:
77
- export interface RoutesMap {
78
- [method: string]: Route[];
79
- }
@@ -1,84 +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";
10
+ import { MIME_TYPES } from "../internal/mimeTypes";
4
11
  import type { CpeakRequest, CpeakResponse, Next } from "../types";
5
12
 
6
- function renderTemplate(
7
- templateStr: string,
8
- data: Record<string, unknown>
9
- ): string {
10
- // Initialize variables
11
- let result: (string | unknown)[] = [];
12
-
13
- let currentIndex = 0;
14
-
15
- while (currentIndex < templateStr.length) {
16
- // Find the next opening placeholder
17
- const startIdx = templateStr.indexOf("{{", currentIndex);
18
- if (startIdx === -1) {
19
- // No more placeholders, push the remaining string
20
- result.push(templateStr.slice(currentIndex));
21
- break;
22
- }
13
+ export const MAX_PATTERN = 128;
23
14
 
24
- // Push the part before the placeholder
25
- result.push(templateStr.slice(currentIndex, startIdx));
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
+ }
26
23
 
27
- // Find the closing placeholder
28
- const endIdx = templateStr.indexOf("}}", startIdx);
29
- if (endIdx === -1) {
30
- // No closing brace found, treat the rest as plain text
31
- result.push(templateStr.slice(startIdx));
32
- break;
33
- }
24
+ class TemplateTransform extends Transform {
25
+ private tail = "";
34
26
 
35
- // Extract the variable name
36
- const varName = templateStr.slice(startIdx + 2, endIdx).trim();
27
+ constructor(
28
+ private readonly data: Record<string, unknown>,
29
+ private readonly baseDir: string
30
+ ) {
31
+ super();
32
+ }
33
+
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
+ }
37
45
 
38
- // Replace the variable with its value from the data, or use an empty string if not found
39
- 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
+ }
40
59
 
41
- // Push the replacement to the result array
42
- 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
+ }
43
68
 
44
- // Move the index past the current closing placeholder
45
- 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();
46
75
  }
47
76
 
48
- // Join all parts into a final string
49
- 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
+ }
50
116
  }
51
117
 
52
- // Errors to return: recommend to not render files larger than 100KB
53
- // To Explore: Doing the operation in C++ and return the data as stream back to the client
54
- // @TODO: remove the file from static map
55
- // @TODO: escape the string to prevent XSS
56
- // @TODO: add another {{{ }}} option to not escape the string
57
118
  const render = () => {
58
119
  return function (req: CpeakRequest, res: CpeakResponse, next: Next): void {
59
120
  res.render = async (
60
- path: string,
121
+ filePath: string,
61
122
  data: Record<string, unknown>,
62
- mime: string
123
+ mime?: string
63
124
  ) => {
64
- // check if mime is specified, if not return an error
125
+ if (res.headersSent) return;
65
126
  if (!mime) {
66
- throw frameworkError(
67
- `MIME type is missing. You called res.render("${path}", ...) but forgot to provide the third "mime" argument.`,
68
- res.render
69
- );
127
+ const dotIndex = filePath.lastIndexOf(".");
128
+ const fileExtension = dotIndex >= 0 ? filePath.slice(dotIndex + 1) : "";
129
+ mime = MIME_TYPES[fileExtension];
130
+ if (!mime) {
131
+ throw frameworkError(
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
135
+ );
136
+ }
70
137
  }
71
138
 
72
- let fileStr = await fs.readFile(path, "utf-8");
73
- const finalStr = renderTemplate(fileStr, data);
74
-
75
- if (res._compression) {
76
- await compressAndSend(res, mime, finalStr, res._compression);
77
- return;
139
+ const resolved = path.resolve(filePath);
140
+
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
+ }
149
+
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
+ );
78
164
  }
79
-
80
- res.setHeader("Content-Type", mime);
81
- res.end(finalStr);
82
165
  };
83
166
 
84
167
  next();
@@ -1,39 +1,45 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- import type { StringMap, CpeakRequest, CpeakResponse, Next } from "../types";
5
-
6
- const MIME_TYPES: StringMap = {
7
- html: "text/html",
8
- css: "text/css",
9
- js: "application/javascript",
10
- jpg: "image/jpeg",
11
- jpeg: "image/jpeg",
12
- png: "image/png",
13
- svg: "image/svg+xml",
14
- txt: "text/plain",
15
- eot: "application/vnd.ms-fontobject",
16
- otf: "font/otf",
17
- ttf: "font/ttf",
18
- woff: "font/woff",
19
- woff2: "font/woff2",
20
- gif: "image/gif",
21
- ico: "image/x-icon",
22
- json: "application/json",
23
- webmanifest: "application/manifest+json"
24
- };
4
+ import { MIME_TYPES } from "../internal/mimeTypes";
5
+ import type { CpeakRequest, CpeakResponse, Next } from "../types";
25
6
 
26
7
  const serveStatic = (
27
8
  folderPath: string,
28
- newMimeTypes?: StringMap,
29
- options?: { prefix?: string }
9
+ options?: { prefix?: string; live?: boolean; exclude?: string[] }
30
10
  ) => {
31
- // For new user defined mime types
32
- if (newMimeTypes) {
33
- Object.assign(MIME_TYPES, newMimeTypes);
11
+ const prefix = options?.prefix ?? "";
12
+ const live = options?.live ?? false;
13
+
14
+ // This process the folder on every request, which is useful during development when files are changing often.
15
+ // In production, it's better to process the folder once and store the file paths in memory for faster access if file names are not changing often.
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
+ if (live) {
18
+ const resolvedFolder = path.resolve(folderPath);
19
+ const excludes = (options?.exclude ?? []).map(e => path.join(resolvedFolder, e));
20
+
21
+ return async function (req: CpeakRequest, res: CpeakResponse, next: Next) {
22
+ const url = req.url;
23
+ if (typeof url !== "string") return next();
24
+
25
+ const pathname = url.split("?")[0];
26
+ const unprefixed = prefix ? pathname.slice(prefix.length) : pathname;
27
+ const filePath = path.join(resolvedFolder, unprefixed);
28
+ const fileExtension = path.extname(filePath).slice(1);
29
+ const mime = MIME_TYPES[fileExtension];
30
+
31
+ if (!mime || !filePath.startsWith(resolvedFolder)) return next();
32
+ if (excludes.some(e => filePath.startsWith(e))) return next();
33
+
34
+ const stat = await fs.promises.stat(filePath).catch(() => null);
35
+ if (stat?.isFile()) return res.sendFile(filePath, mime);
36
+
37
+ next();
38
+ };
34
39
  }
35
40
 
36
- const prefix = options?.prefix ?? "";
41
+ const resolvedFolder = path.resolve(folderPath);
42
+ const excludes = (options?.exclude ?? []).map(e => path.join(resolvedFolder, e));
37
43
 
38
44
  function processFolder(folderPath: string, parentFolder: string) {
39
45
  const staticFiles: string[] = [];
@@ -47,10 +53,12 @@ const serveStatic = (
47
53
 
48
54
  // Check if it's a directory
49
55
  if (fs.statSync(fullPath).isDirectory()) {
56
+ if (excludes.some(e => fullPath.startsWith(e))) continue;
50
57
  // If it's a directory, recursively process it
51
58
  const subfolderFiles = processFolder(fullPath, parentFolder);
52
59
  staticFiles.push(...subfolderFiles);
53
60
  } else {
61
+ if (excludes.some(e => fullPath.startsWith(e))) continue;
54
62
  // If it's a file, add it to the array
55
63
  const relativePath = path.relative(parentFolder, fullPath);
56
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.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "A minimal and fast Node.js HTTP framework.",
5
5
  "type": "module",
6
6
  "scripts": {