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.
- package/README.md +120 -72
- package/dist/index.d.ts +59 -48
- package/dist/index.js +484 -174
- package/dist/index.js.map +1 -1
- package/lib/index.ts +132 -121
- package/lib/internal/errors.ts +51 -0
- package/lib/internal/mimeTypes.ts +31 -0
- package/lib/internal/router.ts +259 -0
- package/lib/types.ts +29 -25
- package/lib/utils/render.ts +142 -59
- package/lib/utils/serveStatic.ts +35 -27
- package/package.json +1 -1
|
@@ -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
|
|
30
|
-
status: (code: number) => CpeakResponse
|
|
31
|
-
attachment: (filename?: string) => CpeakResponse
|
|
32
|
-
cookie: (name: string, value: string, options?:
|
|
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:
|
|
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
|
-
) =>
|
|
63
|
+
) => unknown;
|
|
52
64
|
|
|
53
|
-
// Route middleware:
|
|
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
|
-
|
|
59
|
-
) => void | Promise<void>;
|
|
69
|
+
next: Next
|
|
70
|
+
) => unknown;
|
|
60
71
|
|
|
61
|
-
// Route handlers: (req, res
|
|
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
|
-
|
|
66
|
-
) => void | Promise<void>;
|
|
75
|
+
res: CpeakResponse<ResBody>
|
|
76
|
+
) => unknown;
|
|
67
77
|
|
|
68
|
-
//
|
|
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
|
-
}
|
package/lib/utils/render.ts
CHANGED
|
@@ -1,84 +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";
|
|
10
|
+
import { MIME_TYPES } from "../internal/mimeTypes";
|
|
4
11
|
import type { CpeakRequest, CpeakResponse, Next } from "../types";
|
|
5
12
|
|
|
6
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
+
}
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
121
|
+
filePath: string,
|
|
61
122
|
data: Record<string, unknown>,
|
|
62
|
-
mime
|
|
123
|
+
mime?: string
|
|
63
124
|
) => {
|
|
64
|
-
|
|
125
|
+
if (res.headersSent) return;
|
|
65
126
|
if (!mime) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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();
|
package/lib/utils/serveStatic.ts
CHANGED
|
@@ -1,39 +1,45 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import
|
|
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
|
-
|
|
29
|
-
options?: { prefix?: string }
|
|
9
|
+
options?: { prefix?: string; live?: boolean; exclude?: string[] }
|
|
30
10
|
) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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);
|