cpeak 2.6.0 → 2.8.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
+ }
@@ -0,0 +1,10 @@
1
+ export interface CompressionOptions {
2
+ // Responses with a Content-Length below this many bytes are sent uncompressed.
3
+ // Below ~1KB, TCP/TLS framing overhead can outweigh the savings.
4
+ threshold?: number;
5
+ brotli?: import("node:zlib").BrotliOptions;
6
+ gzip?: import("node:zlib").ZlibOptions;
7
+ deflate?: import("node:zlib").ZlibOptions;
8
+ }
9
+
10
+ export type ResolvedCompressionConfig = Required<CompressionOptions>;
package/lib/types.ts CHANGED
@@ -1,7 +1,22 @@
1
- import { IncomingMessage, ServerResponse } from "node:http";
1
+ import { IncomingMessage, ServerResponse, type Server } from "node:http";
2
+ import type { Readable } from "node:stream";
3
+ import type { Buffer } from "node:buffer";
4
+ import type { CompressionOptions } from "./internal/types";
5
+ import type { CpeakIncomingMessage, CpeakServerResponse } from "./index";
2
6
 
3
7
  export type { Cpeak } from "./index";
4
8
 
9
+ export type CpeakHttpServer = Server<
10
+ typeof CpeakIncomingMessage,
11
+ typeof CpeakServerResponse
12
+ >;
13
+
14
+ // For constructor options passed to `cpeak()`
15
+ export interface CpeakOptions {
16
+ compression?: boolean | CompressionOptions;
17
+ mimeTypes?: StringMap;
18
+ }
19
+
5
20
  // Extending Node.js's Request and Response objects to add our custom properties
6
21
  export type StringMap = Record<string, string>;
7
22
 
@@ -18,49 +33,45 @@ export interface CpeakRequest<
18
33
  }
19
34
 
20
35
  export interface CpeakResponse extends ServerResponse {
21
- sendFile: (path: string, mime: string) => Promise<void>;
36
+ sendFile: (path: string, mime?: string) => Promise<void>;
22
37
  status: (code: number) => CpeakResponse;
23
38
  attachment: (filename?: string) => CpeakResponse;
24
39
  cookie: (name: string, value: string, options?: any) => CpeakResponse;
25
40
  redirect: (location: string) => void;
26
- json: (data: any) => void;
41
+ json: (data: any) => Promise<void>;
42
+ compress: (
43
+ mime: string,
44
+ body: Buffer | string | Readable,
45
+ size?: number
46
+ ) => Promise<void>;
27
47
  [key: string]: any; // allow developers to add their onw extensions (e.g. res.test)
28
48
  }
29
49
 
30
50
  export type Next = (err?: any) => void;
31
- export type HandleErr = (err: any) => void;
32
51
 
33
52
  // beforeEach middleware: (req, res, next)
34
53
  export type Middleware<ReqBody = any, ReqParams = any> = (
35
54
  req: CpeakRequest<ReqBody, ReqParams>,
36
55
  res: CpeakResponse,
37
56
  next: Next
38
- ) => void;
57
+ ) => unknown;
39
58
 
40
- // Route middleware: (req, res, next, handleErr)
59
+ // Route middleware: (req, res, next)
41
60
  export type RouteMiddleware<ReqBody = any, ReqParams = any> = (
42
61
  req: CpeakRequest<ReqBody, ReqParams>,
43
62
  res: CpeakResponse,
44
- next: Next,
45
- handleErr: HandleErr
46
- ) => void | Promise<void>;
63
+ next: Next
64
+ ) => unknown;
47
65
 
48
- // Route handlers: (req, res, handleErr)
66
+ // Route handlers: (req, res). To signal an error, throw it.
49
67
  export type Handler<ReqBody = any, ReqParams = any> = (
50
68
  req: CpeakRequest<ReqBody, ReqParams>,
51
- res: CpeakResponse,
52
- handleErr: HandleErr
53
- ) => void | Promise<void>;
69
+ res: CpeakResponse
70
+ ) => unknown;
54
71
 
55
- // For a route object value in Cpeak.routes. The key is the method name.
72
+ // Represents a single registered route.
56
73
  export interface Route {
57
74
  path: string;
58
- regex: RegExp;
59
75
  middleware: RouteMiddleware[];
60
76
  cb: Handler;
61
77
  }
62
-
63
- // For Cpeak.routes:
64
- export interface RoutesMap {
65
- [method: string]: Route[];
66
- }
package/lib/utils/auth.ts CHANGED
@@ -2,6 +2,7 @@ import { randomBytes, pbkdf2, createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { promisify } from "node:util";
3
3
  import type { Middleware } from "../types";
4
4
  import { frameworkError, ErrorCode } from "../index";
5
+ import type { AuthOptions, PbkdfOptions } from "./types";
5
6
 
6
7
  const pbkdf2Async = promisify(pbkdf2);
7
8
 
@@ -15,29 +16,6 @@ const DEFAULTS = {
15
16
  tokenExpiry: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
16
17
  } as const;
17
18
 
18
- export interface PbkdfOptions {
19
- iterations?: number;
20
- keylen?: number;
21
- digest?: string;
22
- saltSize?: number;
23
- }
24
-
25
- export interface AuthOptions extends PbkdfOptions {
26
- secret: string;
27
- saveToken: (
28
- tokenId: string,
29
- userId: string,
30
- expiresAt: Date
31
- ) => Promise<void>;
32
- findToken: (
33
- tokenId: string
34
- ) => Promise<{ userId: string; expiresAt: Date } | null>;
35
- tokenExpiry?: number;
36
- hmacAlgorithm?: string;
37
- tokenIdSize?: number;
38
- revokeToken?: (tokenId: string) => Promise<void>;
39
- }
40
-
41
19
  export async function hashPassword(
42
20
  password: string,
43
21
  options?: PbkdfOptions
@@ -1,17 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import type { CpeakRequest, CpeakResponse, Next } from "../types";
3
3
  import { frameworkError, ErrorCode } from "../index";
4
-
5
- export interface CookieOptions {
6
- signed?: boolean;
7
- httpOnly?: boolean;
8
- secure?: boolean;
9
- sameSite?: "strict" | "lax" | "none";
10
- maxAge?: number; // ms
11
- expires?: Date;
12
- path?: string;
13
- domain?: string;
14
- }
4
+ import type { CookieOptions } from "./types";
15
5
 
16
6
  // This will sign the cookie value with HMAC with the secret.
17
7
  // Ideal for data like user IDs or session IDs, where you want to ensure the integrity of the cookie value without encryption.
@@ -0,0 +1,109 @@
1
+ import type { CpeakRequest, CpeakResponse, Next } from "../types";
2
+ import type { CorsOptions, OriginInput } from "./types";
3
+
4
+ // Append a value to an existing header without overwriting prior entries
5
+ // (e.g. compression already sets `Vary: Accept-Encoding`).
6
+ function appendVary(res: CpeakResponse, value: string) {
7
+ const existing = res.getHeader("Vary");
8
+ if (!existing) return res.setHeader("Vary", value);
9
+ const current = String(existing)
10
+ .split(",")
11
+ .map((s) => s.trim())
12
+ .filter(Boolean);
13
+ if (current.includes("*") || current.includes(value)) return;
14
+ res.setHeader("Vary", [...current, value].join(", "));
15
+ }
16
+
17
+ // Determine if the given origin is allowed based on the rule.
18
+ // Examples of what developers can specify for the rule:
19
+ // - `true` or `*`: allow all origins
20
+ // - `false`: disallow all origins
21
+ // - `"https://example.com"`: allow only this origin
22
+ // - `["https://example.com", "https://foo.com"]`: allow these origins
23
+ // - `/\.example\.com$/`: allow origins that match this regex
24
+ // - `(origin) => origin === "https://example.com"`: custom function to determine if the origin is allowed
25
+ async function isAllowed(
26
+ origin: string | undefined,
27
+ rule: OriginInput
28
+ ): Promise<boolean> {
29
+ if (rule === true || rule === "*") return true;
30
+ if (rule === false || !origin) return false;
31
+ if (typeof rule === "string") return rule === origin;
32
+ if (Array.isArray(rule)) return rule.includes(origin);
33
+ if (rule instanceof RegExp) return rule.test(origin);
34
+ if (typeof rule === "function") return await rule(origin);
35
+ return false;
36
+ }
37
+
38
+ const cors = (options: CorsOptions = {}) => {
39
+ const {
40
+ origin = "*",
41
+ methods = "GET,HEAD,PUT,PATCH,POST,DELETE",
42
+ allowedHeaders,
43
+ exposedHeaders,
44
+ credentials = false,
45
+ maxAge = 86400,
46
+ preflightContinue = false,
47
+ optionsSuccessStatus = 204
48
+ } = options;
49
+
50
+ const methodsStr = Array.isArray(methods) ? methods.join(",") : methods;
51
+ const allowedHeadersStr = Array.isArray(allowedHeaders)
52
+ ? allowedHeaders.join(",")
53
+ : allowedHeaders;
54
+ const exposedHeadersStr = Array.isArray(exposedHeaders)
55
+ ? exposedHeaders.join(",")
56
+ : exposedHeaders;
57
+
58
+ return async (req: CpeakRequest, res: CpeakResponse, next: Next) => {
59
+ const requestOrigin = req.headers.origin;
60
+
61
+ // Not a CORS request, nothing to do.
62
+ if (!requestOrigin) return next();
63
+
64
+ const allowed = await isAllowed(requestOrigin, origin);
65
+ if (!allowed) return next();
66
+
67
+ // We cannot combine Access-Control-Allow-Origin: * with
68
+ // Access-Control-Allow-Credentials: true. Browsers will flat-out reject it.
69
+ // Instead we'll reflect the origin.
70
+ const allowOriginValue =
71
+ origin === "*" && !credentials ? "*" : requestOrigin;
72
+ res.setHeader("Access-Control-Allow-Origin", allowOriginValue);
73
+ if (allowOriginValue !== "*") appendVary(res, "Origin");
74
+
75
+ if (credentials) res.setHeader("Access-Control-Allow-Credentials", "true");
76
+ if (exposedHeadersStr)
77
+ res.setHeader("Access-Control-Expose-Headers", exposedHeadersStr);
78
+
79
+ const isPreflight =
80
+ req.method === "OPTIONS" &&
81
+ req.headers["access-control-request-method"] !== undefined;
82
+
83
+ if (!isPreflight) return next();
84
+
85
+ res.setHeader("Access-Control-Allow-Methods", methodsStr);
86
+
87
+ if (allowedHeadersStr) {
88
+ res.setHeader("Access-Control-Allow-Headers", allowedHeadersStr);
89
+ } else if (origin === "*") {
90
+ // If origin is *, just act like an echo chamber for requested headers. Give back whatever the browser asks for.
91
+ const requested = req.headers["access-control-request-headers"];
92
+ if (requested) res.setHeader("Access-Control-Allow-Headers", requested);
93
+ } else {
94
+ res.setHeader(
95
+ "Access-Control-Allow-Headers",
96
+ "Content-Type, Authorization"
97
+ );
98
+ }
99
+
100
+ res.setHeader("Access-Control-Max-Age", String(maxAge));
101
+
102
+ if (preflightContinue) return next();
103
+
104
+ res.statusCode = optionsSuccessStatus;
105
+ res.end();
106
+ };
107
+ };
108
+
109
+ export { cors };
@@ -3,9 +3,8 @@ import { serveStatic } from "./serveStatic";
3
3
  import { render } from "./render";
4
4
  import { swagger } from "./swagger";
5
5
  import { auth, hashPassword, verifyPassword } from "./auth";
6
- import type { AuthOptions, PbkdfOptions } from "./auth";
7
6
  import { cookieParser } from "./cookieParser";
8
- import type { CookieOptions } from "./cookieParser";
7
+ import { cors } from "./cors";
9
8
 
10
9
  export {
11
10
  serveStatic,
@@ -15,6 +14,6 @@ export {
15
14
  auth,
16
15
  hashPassword,
17
16
  verifyPassword,
18
- cookieParser
17
+ cookieParser,
18
+ cors
19
19
  };
20
- export type { AuthOptions, PbkdfOptions, CookieOptions };
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { frameworkError } from "../";
3
+ import { compressAndSend } from "../internal/compression";
4
+ import { MIME_TYPES } from "../internal/mimeTypes";
3
5
  import type { CpeakRequest, CpeakResponse, Next } from "../types";
4
6
 
5
7
  function renderTemplate(
@@ -58,18 +60,28 @@ const render = () => {
58
60
  res.render = async (
59
61
  path: string,
60
62
  data: Record<string, unknown>,
61
- mime: string
63
+ mime?: string
62
64
  ) => {
63
- // check if mime is specified, if not return an error
64
65
  if (!mime) {
65
- throw frameworkError(
66
- `MIME type is missing. You called res.render("${path}", ...) but forgot to provide the third "mime" argument.`,
67
- res.render
68
- );
66
+ const dotIndex = path.lastIndexOf(".");
67
+ const fileExtension = dotIndex >= 0 ? path.slice(dotIndex + 1) : "";
68
+ mime = MIME_TYPES[fileExtension];
69
+ if (!mime) {
70
+ 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
73
+ );
74
+ }
69
75
  }
70
76
 
71
77
  let fileStr = await fs.readFile(path, "utf-8");
72
78
  const finalStr = renderTemplate(fileStr, data);
79
+
80
+ if (res._compression) {
81
+ await compressAndSend(res, mime, finalStr, res._compression);
82
+ return;
83
+ }
84
+
73
85
  res.setHeader("Content-Type", mime);
74
86
  res.end(finalStr);
75
87
  };
@@ -1,39 +1,40 @@
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 }
30
10
  ) => {
31
- // For new user defined mime types
32
- if (newMimeTypes) {
33
- Object.assign(MIME_TYPES, newMimeTypes);
34
- }
35
-
36
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
+
20
+ return async function (req: CpeakRequest, res: CpeakResponse, next: Next) {
21
+ const url = req.url;
22
+ if (typeof url !== "string") return next();
23
+
24
+ const pathname = url.split("?")[0];
25
+ const unprefixed = prefix ? pathname.slice(prefix.length) : pathname;
26
+ const filePath = path.join(resolvedFolder, unprefixed);
27
+ const fileExtension = path.extname(filePath).slice(1);
28
+ const mime = MIME_TYPES[fileExtension];
29
+
30
+ if (!mime || !filePath.startsWith(resolvedFolder)) return next();
31
+
32
+ const stat = await fs.promises.stat(filePath).catch(() => null);
33
+ if (stat?.isFile()) return res.sendFile(filePath, mime);
34
+
35
+ next();
36
+ };
37
+ }
37
38
 
38
39
  function processFolder(folderPath: string, parentFolder: string) {
39
40
  const staticFiles: string[] = [];