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/lib/index.ts CHANGED
@@ -2,6 +2,8 @@ import http from "node:http";
2
2
  import fs from "node:fs/promises";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { pipeline } from "node:stream/promises";
5
+
6
+ import type net from "node:net";
5
7
  import type { Readable } from "node:stream";
6
8
  import type { Buffer } from "node:buffer";
7
9
 
@@ -9,52 +11,29 @@ import {
9
11
  resolveCompressionOptions,
10
12
  compressAndSend
11
13
  } from "./internal/compression";
14
+ import { MIME_TYPES } from "./internal/mimeTypes";
15
+ import { Router } from "./internal/router";
16
+ import {
17
+ frameworkError,
18
+ ErrorCode,
19
+ isClientDisconnect
20
+ } from "./internal/errors";
21
+
22
+ export { frameworkError, ErrorCode, isClientDisconnect };
12
23
 
13
24
  import type {
14
25
  StringMap,
26
+ CpeakHttpServer,
15
27
  CpeakOptions,
16
28
  CpeakRequest,
17
29
  CpeakResponse,
18
30
  Middleware,
19
31
  RouteMiddleware,
20
- Handler,
21
- RoutesMap
32
+ Handler
22
33
  } from "./types";
23
34
 
24
35
  import type { ResolvedCompressionConfig } from "./internal/types";
25
36
 
26
- // A utility function to create an error with a custom stack trace
27
- export function frameworkError(
28
- message: string,
29
- skipFn: Function,
30
- code?: string,
31
- status?: number
32
- ) {
33
- const err = new Error(message) as Error & {
34
- code?: string;
35
- cpeak_err?: boolean;
36
- };
37
- Error.captureStackTrace(err, skipFn);
38
-
39
- err.cpeak_err = true;
40
-
41
- if (code) err.code = code;
42
- if (status) (err as any).status = status;
43
-
44
- return err;
45
- }
46
-
47
- export enum ErrorCode {
48
- MISSING_MIME = "CPEAK_ERR_MISSING_MIME",
49
- FILE_NOT_FOUND = "CPEAK_ERR_FILE_NOT_FOUND",
50
- NOT_A_FILE = "CPEAK_ERR_NOT_A_FILE",
51
- SEND_FILE_FAIL = "CPEAK_ERR_SEND_FILE_FAIL",
52
- INVALID_JSON = "CPEAK_ERR_INVALID_JSON",
53
- PAYLOAD_TOO_LARGE = "CPEAK_ERR_PAYLOAD_TOO_LARGE",
54
- WEAK_SECRET = "CPEAK_ERR_WEAK_SECRET",
55
- COMPRESSION_NOT_ENABLED = "CPEAK_ERR_COMPRESSION_NOT_ENABLED"
56
- }
57
-
58
37
  export class CpeakIncomingMessage extends http.IncomingMessage {
59
38
  // We define body and params here for better V8 optimization (not changing the shape of the object at runtime)
60
39
  public body: any = undefined;
@@ -88,13 +67,19 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
88
67
  _compression?: ResolvedCompressionConfig;
89
68
 
90
69
  // Send a file back to the client
91
- async sendFile(path: string, mime: string) {
70
+ async sendFile(path: string, mime?: string) {
71
+ if (this.headersSent) return;
92
72
  if (!mime) {
93
- throw frameworkError(
94
- 'MIME type is missing. Use res.sendFile(path, "mime-type").',
95
- this.sendFile,
96
- ErrorCode.MISSING_MIME
97
- );
73
+ const dotIndex = path.lastIndexOf(".");
74
+ const fileExtension = dotIndex >= 0 ? path.slice(dotIndex + 1) : "";
75
+ mime = MIME_TYPES[fileExtension];
76
+ if (!mime) {
77
+ throw frameworkError(
78
+ `MIME type is missing for "${path}". Pass it as the second argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
79
+ this.sendFile,
80
+ ErrorCode.MISSING_MIME
81
+ );
82
+ }
98
83
  }
99
84
 
100
85
  try {
@@ -135,7 +120,9 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
135
120
  throw frameworkError(
136
121
  `Failed to send file: ${path}`,
137
122
  this.sendFile,
138
- ErrorCode.SEND_FILE_FAIL
123
+ ErrorCode.SEND_FILE_FAIL,
124
+ undefined,
125
+ isClientDisconnect(err)
139
126
  );
140
127
  }
141
128
  }
@@ -163,14 +150,23 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
163
150
 
164
151
  // Send a json data back to the client.
165
152
  // This is only good for bodies that their size is less than the highWaterMark value.
166
- // Branches into compressAndSend (async) when compression was enabled at cpeak() construction.
167
- json(data: any): void | Promise<void> {
153
+ json(data: any): Promise<void> {
154
+ if (this.headersSent) return Promise.resolve();
168
155
  const body = JSON.stringify(data);
169
156
  if (this._compression) {
170
157
  return compressAndSend(this, "application/json", body, this._compression);
171
158
  }
172
159
  this.setHeader("Content-Type", "application/json");
173
160
  this.end(body);
161
+ return Promise.resolve();
162
+ }
163
+
164
+ render(): Promise<void> {
165
+ throw frameworkError(
166
+ "render middleware not registered. Add render() via app.beforeEach(render()) to use res.render.",
167
+ this.render,
168
+ ErrorCode.RENDER_NOT_ENABLED
169
+ );
174
170
  }
175
171
 
176
172
  // Explicit compression entry point. A developer can use this in any custom handler to compress arbitrary responses
@@ -179,6 +175,7 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
179
175
  body: Buffer | string | Readable,
180
176
  size?: number
181
177
  ): Promise<void> {
178
+ if (this.headersSent) return Promise.resolve();
182
179
  if (!this._compression) {
183
180
  throw frameworkError(
184
181
  "compression is not enabled. Pass `compression` to cpeak({ compression: true | { ... } }) to use res.compress.",
@@ -191,10 +188,11 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
191
188
  }
192
189
 
193
190
  export class Cpeak {
194
- #server: http.Server<typeof CpeakIncomingMessage, typeof CpeakServerResponse>;
195
- #routes: RoutesMap;
191
+ #server: CpeakHttpServer;
192
+ #router: Router;
196
193
  #middleware: Middleware[];
197
194
  #handleErr?: (err: unknown, req: CpeakRequest, res: CpeakResponse) => void;
195
+ #fallback?: Handler;
198
196
  #compression?: ResolvedCompressionConfig;
199
197
 
200
198
  constructor(options: CpeakOptions = {}) {
@@ -202,7 +200,7 @@ export class Cpeak {
202
200
  IncomingMessage: CpeakIncomingMessage,
203
201
  ServerResponse: CpeakServerResponse
204
202
  });
205
- this.#routes = {};
203
+ this.#router = new Router();
206
204
  this.#middleware = [];
207
205
 
208
206
  // Resolve compression options once at app startup.
@@ -210,6 +208,9 @@ export class Cpeak {
210
208
  this.#compression = resolveCompressionOptions(options.compression);
211
209
  }
212
210
 
211
+ // Merge developer-supplied mime types with the defaults once at startup
212
+ if (options.mimeTypes) Object.assign(MIME_TYPES, options.mimeTypes);
213
+
213
214
  this.#server.on(
214
215
  "request",
215
216
  async (req: CpeakRequest, res: CpeakResponse) => {
@@ -220,13 +221,37 @@ export class Cpeak {
220
221
  const urlWithoutQueries =
221
222
  qIndex === -1 ? req.url || "" : req.url?.substring(0, qIndex);
222
223
 
223
- const dispatchError = (error: unknown) => {
224
+ // Routes every error path through the registered handleErr. Awaits
225
+ // handleErr so its own async work (or a rejecting res.json under
226
+ // compression) is caught. If handleErr itself fails, we log and send a
227
+ // bare 500 so the client never gets a hung socket. Returns a Promise
228
+ // that never rejects to avoid unhandled promise rejections in case of errors in handleErr.
229
+ const dispatchError = async (error: unknown) => {
224
230
  if (res.headersSent) {
225
231
  req.socket?.destroy();
226
- return;
232
+ } else {
233
+ res.setHeader("Connection", "close");
234
+ }
235
+
236
+ if (isClientDisconnect(error) && !(error as any).clientDisconnect) {
237
+ (error as any).clientDisconnect = true;
238
+ }
239
+ try {
240
+ await this.#handleErr?.(error, req, res);
241
+ } catch (handlerFailure) {
242
+ console.error(
243
+ "[cpeak] handleErr failed while processing:",
244
+ error,
245
+ "\nReason:",
246
+ handlerFailure
247
+ );
248
+ if (!res.headersSent) {
249
+ try {
250
+ res.statusCode = 500;
251
+ res.end();
252
+ } catch {}
253
+ }
227
254
  }
228
- res.setHeader("Connection", "close");
229
- this.#handleErr?.(error, req, res);
230
255
  };
231
256
 
232
257
  // Run all the specific middleware functions for that router only and then run the handler
@@ -240,29 +265,22 @@ export class Cpeak {
240
265
  // Our exit point...
241
266
  if (index === middleware.length) {
242
267
  // Call the route handler with the modified req and res objects.
243
- // Also handle the promise errors by passing them to the handleErr to save developers from having to manually wrap every handler in try catch.
268
+ // Also handle the promise errors by passing them to handleErr to save developers from having to manually wrap every handler in try/catch.
244
269
  try {
245
- await cb(req, res, dispatchError);
270
+ await cb(req, res);
246
271
  } catch (error) {
247
272
  dispatchError(error);
248
273
  }
249
274
  } else {
250
- // Handle the promise errors by passing them to the handleErr to save developers from having to manually wrap every handler middleware in try catch.
275
+ // Handle the promise errors by passing them to handleErr to save developers from having to manually wrap every route middleware in try/catch.
251
276
  try {
252
- await middleware[index](
253
- req,
254
- res,
255
- // The next function
256
- async (error) => {
257
- // this function only accepts an error argument to be more compatible with NPM modules that are built for express
258
- if (error) {
259
- return dispatchError(error);
260
- }
261
- await runHandler(req, res, middleware, cb, index + 1);
262
- },
263
- // Error handler for a route middleware
264
- dispatchError
265
- );
277
+ await middleware[index](req, res, async (error?: unknown) => {
278
+ // this function only accepts an error argument to be more compatible with NPM modules that are built for express
279
+ if (error) {
280
+ return dispatchError(error);
281
+ }
282
+ await runHandler(req, res, middleware, cb, index + 1);
283
+ });
266
284
  } catch (error) {
267
285
  dispatchError(error);
268
286
  }
@@ -278,32 +296,30 @@ export class Cpeak {
278
296
  ) => {
279
297
  // Our exit point...
280
298
  if (index === middleware.length) {
281
- const routes = this.#routes[req.method?.toLowerCase() || ""];
282
- if (routes && typeof routes[Symbol.iterator] === "function")
283
- for (const route of routes) {
284
- const match = urlWithoutQueries?.match(route.regex);
285
-
286
- if (match) {
287
- // Parse the URL path variables from the matched route (like /users/:id)
288
- const pathVariables = this.#extractPathVariables(
289
- route.path,
290
- match
291
- );
292
-
293
- // We will call this params to be more familiar with other node.js frameworks.
294
- req.params = pathVariables;
295
-
296
- return await runHandler(
297
- req,
298
- res,
299
- route.middleware,
300
- route.cb,
301
- 0
302
- );
303
- }
299
+ const method = req.method?.toLowerCase() || "";
300
+ const found = this.#router.find(method, urlWithoutQueries || "");
301
+
302
+ if (found) {
303
+ req.params = found.params;
304
+ return await runHandler(
305
+ req,
306
+ res,
307
+ found.middleware,
308
+ found.handler,
309
+ 0
310
+ );
311
+ }
312
+
313
+ // If a fallback handler is registered, run it before falling back to the default 404
314
+ if (this.#fallback) {
315
+ try {
316
+ return await this.#fallback(req, res);
317
+ } catch (error) {
318
+ return dispatchError(error);
304
319
  }
320
+ }
305
321
 
306
- // If the requested route dose not exist, return 404
322
+ // If the requested route dose not exist, and developer has not registered the fallback handler, return 404
307
323
  return res
308
324
  .status(404)
309
325
  .json({ error: `Cannot ${req.method} ${urlWithoutQueries}` });
@@ -327,8 +343,6 @@ export class Cpeak {
327
343
  }
328
344
 
329
345
  route(method: string, path: string, ...args: (RouteMiddleware | Handler)[]) {
330
- if (!this.#routes[method]) this.#routes[method] = [];
331
-
332
346
  // The last argument should always be our handler
333
347
  const cb = args.pop() as Handler;
334
348
 
@@ -339,8 +353,7 @@ export class Cpeak {
339
353
  // Rest will be our middleware functions
340
354
  const middleware = args.flat() as RouteMiddleware[];
341
355
 
342
- const regex = this.#pathToRegex(path);
343
- this.#routes[method].push({ path, regex, middleware, cb });
356
+ this.#router.add(method, path, middleware, cb);
344
357
  }
345
358
 
346
359
  beforeEach(cb: Middleware) {
@@ -351,8 +364,24 @@ export class Cpeak {
351
364
  this.#handleErr = cb;
352
365
  }
353
366
 
354
- listen(port: number, cb?: () => void) {
355
- return this.#server.listen(port, cb);
367
+ // This will handle any request that doesn't match any of the routes and middleware functions
368
+ fallback(cb: Handler) {
369
+ if (this.#fallback) {
370
+ throw frameworkError(
371
+ "Fallback handler is already registered. Only one fallback can be set per app.",
372
+ this.fallback,
373
+ ErrorCode.DUPLICATE_FALLBACK
374
+ );
375
+ }
376
+ this.#fallback = cb;
377
+ }
378
+
379
+ // The first 3 listens are just TS overloads for better type inference and editor autocompletion. The last one is the actual implementation.
380
+ listen(port: number, cb?: () => void): CpeakHttpServer;
381
+ listen(port: number, host: string, cb?: () => void): CpeakHttpServer;
382
+ listen(options: net.ListenOptions, cb?: () => void): CpeakHttpServer;
383
+ listen(...args: any[]) {
384
+ return this.#server.listen(...args);
356
385
  }
357
386
 
358
387
  address() {
@@ -360,29 +389,12 @@ export class Cpeak {
360
389
  }
361
390
 
362
391
  close(cb?: (err?: Error) => void) {
363
- this.#server.close(cb);
392
+ return this.#server.close(cb);
364
393
  }
365
394
 
366
- // ------------------------------
367
- // PRIVATE METHODS:
368
- // ------------------------------
369
- #pathToRegex(path: string) {
370
- const regexString =
371
- "^" + path.replace(/:\w+/g, "([^/]+)").replace(/\*/g, ".*") + "$";
372
-
373
- return new RegExp(regexString);
374
- }
375
-
376
- #extractPathVariables(path: string, match: RegExpMatchArray) {
377
- // Extract path url variable values from the matched route
378
- const paramNames = (path.match(/:\w+/g) || []).map((param) =>
379
- param.slice(1)
380
- );
381
- const params: StringMap = {};
382
- paramNames.forEach((name, index) => {
383
- params[name] = match[index + 1];
384
- });
385
- return params;
395
+ // A getter for developers who want to access the underlying http server instance for advanced use cases that aren't covered by Cpeak
396
+ get server() {
397
+ return this.#server;
386
398
  }
387
399
  }
388
400
 
@@ -409,15 +421,14 @@ export type {
409
421
  export type { CompressionOptions } from "./internal/types";
410
422
 
411
423
  export type {
424
+ CpeakHttpServer,
412
425
  CpeakOptions,
413
426
  CpeakRequest,
414
427
  CpeakResponse,
415
428
  Next,
416
- HandleErr,
417
429
  Middleware,
418
430
  RouteMiddleware,
419
- Handler,
420
- RoutesMap
431
+ Handler
421
432
  } from "./types";
422
433
 
423
434
  export default function cpeak(options?: CpeakOptions): Cpeak {
@@ -0,0 +1,51 @@
1
+ // A utility function to create an error with a custom stack trace
2
+ export function frameworkError(
3
+ message: string,
4
+ skipFn: Function,
5
+ code?: string,
6
+ status?: number,
7
+ clientDisconnect?: boolean
8
+ ) {
9
+ const err = new Error(message) as Error & {
10
+ code?: string;
11
+ cpeak_err?: boolean;
12
+ clientDisconnect?: boolean;
13
+ };
14
+ Error.captureStackTrace(err, skipFn);
15
+
16
+ err.cpeak_err = true;
17
+
18
+ if (code) err.code = code;
19
+ if (status) (err as any).status = status;
20
+ if (clientDisconnect) err.clientDisconnect = true;
21
+
22
+ return err;
23
+ }
24
+
25
+ const CLIENT_DISCONNECT_CODES = new Set([
26
+ "ERR_STREAM_PREMATURE_CLOSE",
27
+ "ERR_STREAM_DESTROYED",
28
+ "ECONNRESET",
29
+ "EPIPE"
30
+ ]);
31
+
32
+ export function isClientDisconnect(err: unknown): boolean {
33
+ return CLIENT_DISCONNECT_CODES.has((err as any)?.code);
34
+ }
35
+
36
+ export enum ErrorCode {
37
+ MISSING_MIME = "CPEAK_ERR_MISSING_MIME",
38
+ FILE_NOT_FOUND = "CPEAK_ERR_FILE_NOT_FOUND",
39
+ NOT_A_FILE = "CPEAK_ERR_NOT_A_FILE",
40
+ SEND_FILE_FAIL = "CPEAK_ERR_SEND_FILE_FAIL",
41
+ INVALID_JSON = "CPEAK_ERR_INVALID_JSON",
42
+ PAYLOAD_TOO_LARGE = "CPEAK_ERR_PAYLOAD_TOO_LARGE",
43
+ WEAK_SECRET = "CPEAK_ERR_WEAK_SECRET",
44
+ COMPRESSION_NOT_ENABLED = "CPEAK_ERR_COMPRESSION_NOT_ENABLED",
45
+ RENDER_NOT_ENABLED = "CPEAK_ERR_RENDER_NOT_ENABLED",
46
+ // For router:
47
+ DUPLICATE_ROUTE = "CPEAK_ERR_DUPLICATE_ROUTE",
48
+ INVALID_ROUTE = "CPEAK_ERR_INVALID_ROUTE",
49
+ DUPLICATE_FALLBACK = "CPEAK_ERR_DUPLICATE_FALLBACK",
50
+ RENDER_FAIL = "CPEAK_ERR_RENDER_FAIL"
51
+ }
@@ -0,0 +1,31 @@
1
+ import type { StringMap } from "../types";
2
+
3
+ // Developers can expand this if needed in the cpeak() constructor
4
+ export const MIME_TYPES: StringMap = {
5
+ html: "text/html",
6
+ css: "text/css",
7
+ js: "application/javascript",
8
+ jpg: "image/jpeg",
9
+ jpeg: "image/jpeg",
10
+ png: "image/png",
11
+ svg: "image/svg+xml",
12
+ txt: "text/plain",
13
+ eot: "application/vnd.ms-fontobject",
14
+ otf: "font/otf",
15
+ ttf: "font/ttf",
16
+ woff: "font/woff",
17
+ woff2: "font/woff2",
18
+ gif: "image/gif",
19
+ ico: "image/x-icon",
20
+ json: "application/json",
21
+ map: "application/json",
22
+ webmanifest: "application/manifest+json",
23
+ xml: "application/xml",
24
+ pdf: "application/pdf",
25
+ mp4: "video/mp4",
26
+ webm: "video/webm",
27
+ mp3: "audio/mpeg",
28
+ wav: "audio/wav",
29
+ webp: "image/webp",
30
+ avif: "image/avif"
31
+ };