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/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // lib/index.ts
2
2
  import http from "http";
3
- import fs3 from "fs/promises";
4
- import { createReadStream } from "fs";
5
- import { pipeline as pipeline2 } from "stream/promises";
3
+ import fs2 from "fs/promises";
4
+ import { createReadStream as createReadStream2 } from "fs";
5
+ import { pipeline as pipeline3 } from "stream/promises";
6
6
 
7
- // lib/utils/compression.ts
7
+ // lib/internal/compression.ts
8
8
  import zlib from "zlib";
9
9
  import { Readable } from "stream";
10
10
  import { Buffer as Buffer2 } from "buffer";
@@ -114,6 +114,226 @@ async function compressAndSend(res, mime, body, config, size) {
114
114
  );
115
115
  }
116
116
 
117
+ // lib/internal/mimeTypes.ts
118
+ var MIME_TYPES = {
119
+ html: "text/html",
120
+ css: "text/css",
121
+ js: "application/javascript",
122
+ jpg: "image/jpeg",
123
+ jpeg: "image/jpeg",
124
+ png: "image/png",
125
+ svg: "image/svg+xml",
126
+ txt: "text/plain",
127
+ eot: "application/vnd.ms-fontobject",
128
+ otf: "font/otf",
129
+ ttf: "font/ttf",
130
+ woff: "font/woff",
131
+ woff2: "font/woff2",
132
+ gif: "image/gif",
133
+ ico: "image/x-icon",
134
+ json: "application/json",
135
+ map: "application/json",
136
+ webmanifest: "application/manifest+json",
137
+ xml: "application/xml",
138
+ pdf: "application/pdf",
139
+ mp4: "video/mp4",
140
+ webm: "video/webm",
141
+ mp3: "audio/mpeg",
142
+ wav: "audio/wav",
143
+ webp: "image/webp",
144
+ avif: "image/avif"
145
+ };
146
+
147
+ // lib/internal/errors.ts
148
+ function frameworkError(message, skipFn, code, status, clientDisconnect) {
149
+ const err = new Error(message);
150
+ Error.captureStackTrace(err, skipFn);
151
+ err.cpeak_err = true;
152
+ if (code) err.code = code;
153
+ if (status) err.status = status;
154
+ if (clientDisconnect) err.clientDisconnect = true;
155
+ return err;
156
+ }
157
+ var CLIENT_DISCONNECT_CODES = /* @__PURE__ */ new Set([
158
+ "ERR_STREAM_PREMATURE_CLOSE",
159
+ "ERR_STREAM_DESTROYED",
160
+ "ECONNRESET",
161
+ "EPIPE"
162
+ ]);
163
+ function isClientDisconnect(err) {
164
+ return CLIENT_DISCONNECT_CODES.has(err?.code);
165
+ }
166
+ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
167
+ ErrorCode2["MISSING_MIME"] = "CPEAK_ERR_MISSING_MIME";
168
+ ErrorCode2["FILE_NOT_FOUND"] = "CPEAK_ERR_FILE_NOT_FOUND";
169
+ ErrorCode2["NOT_A_FILE"] = "CPEAK_ERR_NOT_A_FILE";
170
+ ErrorCode2["SEND_FILE_FAIL"] = "CPEAK_ERR_SEND_FILE_FAIL";
171
+ ErrorCode2["INVALID_JSON"] = "CPEAK_ERR_INVALID_JSON";
172
+ ErrorCode2["PAYLOAD_TOO_LARGE"] = "CPEAK_ERR_PAYLOAD_TOO_LARGE";
173
+ ErrorCode2["WEAK_SECRET"] = "CPEAK_ERR_WEAK_SECRET";
174
+ ErrorCode2["COMPRESSION_NOT_ENABLED"] = "CPEAK_ERR_COMPRESSION_NOT_ENABLED";
175
+ ErrorCode2["RENDER_NOT_ENABLED"] = "CPEAK_ERR_RENDER_NOT_ENABLED";
176
+ ErrorCode2["DUPLICATE_ROUTE"] = "CPEAK_ERR_DUPLICATE_ROUTE";
177
+ ErrorCode2["INVALID_ROUTE"] = "CPEAK_ERR_INVALID_ROUTE";
178
+ ErrorCode2["DUPLICATE_FALLBACK"] = "CPEAK_ERR_DUPLICATE_FALLBACK";
179
+ ErrorCode2["RENDER_FAIL"] = "CPEAK_ERR_RENDER_FAIL";
180
+ return ErrorCode2;
181
+ })(ErrorCode || {});
182
+
183
+ // lib/internal/router.ts
184
+ function createNode() {
185
+ return { staticChildren: /* @__PURE__ */ new Map() };
186
+ }
187
+ var Router = class {
188
+ #treesByMethod = /* @__PURE__ */ new Map();
189
+ add(method, path3, middleware, handler) {
190
+ const methodKey = method.toLowerCase();
191
+ let root = this.#treesByMethod.get(methodKey);
192
+ if (!root) {
193
+ root = createNode();
194
+ this.#treesByMethod.set(methodKey, root);
195
+ }
196
+ const segments = splitPath(path3);
197
+ const paramNames = [];
198
+ let currentNode = root;
199
+ for (let i = 0; i < segments.length; i++) {
200
+ const segment = segments[i];
201
+ const isLastSegment = i === segments.length - 1;
202
+ if (segment.length > 1 && segment.startsWith("*")) {
203
+ throw frameworkError(
204
+ `Invalid route "${path3}": named wildcards (e.g. "*name") are not supported. Use a plain "*" at the end of the path.`,
205
+ this.add,
206
+ "CPEAK_ERR_INVALID_ROUTE" /* INVALID_ROUTE */
207
+ );
208
+ }
209
+ if (segment === "*") {
210
+ if (!isLastSegment) {
211
+ throw frameworkError(
212
+ `Invalid route "${path3}": "*" is only allowed as the final path segment.`,
213
+ this.add,
214
+ "CPEAK_ERR_INVALID_ROUTE" /* INVALID_ROUTE */
215
+ );
216
+ }
217
+ if (currentNode.wildcardChild) {
218
+ throw frameworkError(
219
+ `Duplicate route: ${method.toUpperCase()} ${path3}`,
220
+ this.add,
221
+ "CPEAK_ERR_DUPLICATE_ROUTE" /* DUPLICATE_ROUTE */
222
+ );
223
+ }
224
+ currentNode.wildcardChild = { handler, middleware, paramNames };
225
+ return;
226
+ }
227
+ if (segment.startsWith(":")) {
228
+ const paramName = segment.slice(1);
229
+ if (!paramName) {
230
+ throw frameworkError(
231
+ `Invalid route "${path3}": empty parameter name.`,
232
+ this.add,
233
+ "CPEAK_ERR_INVALID_ROUTE" /* INVALID_ROUTE */
234
+ );
235
+ }
236
+ paramNames.push(paramName);
237
+ if (!currentNode.paramChild) {
238
+ currentNode.paramChild = createNode();
239
+ }
240
+ currentNode = currentNode.paramChild;
241
+ continue;
242
+ }
243
+ let staticChild = currentNode.staticChildren.get(segment);
244
+ if (!staticChild) {
245
+ staticChild = createNode();
246
+ currentNode.staticChildren.set(segment, staticChild);
247
+ }
248
+ currentNode = staticChild;
249
+ }
250
+ if (currentNode.handler) {
251
+ throw frameworkError(
252
+ `Duplicate route: ${method.toUpperCase()} ${path3}`,
253
+ this.add,
254
+ "CPEAK_ERR_DUPLICATE_ROUTE" /* DUPLICATE_ROUTE */
255
+ );
256
+ }
257
+ currentNode.handler = handler;
258
+ currentNode.middleware = middleware;
259
+ currentNode.paramNames = paramNames;
260
+ }
261
+ find(method, path3) {
262
+ const root = this.#treesByMethod.get(method.toLowerCase());
263
+ if (!root) return null;
264
+ const segments = splitPath(path3);
265
+ return matchSegments(root, segments, 0, []);
266
+ }
267
+ };
268
+ function matchSegments(node, segments, segmentIndex, capturedValues) {
269
+ if (segmentIndex === segments.length) {
270
+ if (node.handler) {
271
+ return {
272
+ middleware: node.middleware,
273
+ handler: node.handler,
274
+ params: zipParams(node.paramNames, capturedValues)
275
+ };
276
+ }
277
+ if (node.wildcardChild) {
278
+ return {
279
+ middleware: node.wildcardChild.middleware,
280
+ handler: node.wildcardChild.handler,
281
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
282
+ };
283
+ }
284
+ return null;
285
+ }
286
+ const segment = segments[segmentIndex];
287
+ const staticChild = node.staticChildren.get(segment);
288
+ if (staticChild) {
289
+ const foundMatch = matchSegments(
290
+ staticChild,
291
+ segments,
292
+ segmentIndex + 1,
293
+ capturedValues
294
+ );
295
+ if (foundMatch) return foundMatch;
296
+ }
297
+ if (node.paramChild) {
298
+ capturedValues.push(safeDecode(segment));
299
+ const foundMatch = matchSegments(
300
+ node.paramChild,
301
+ segments,
302
+ segmentIndex + 1,
303
+ capturedValues
304
+ );
305
+ if (foundMatch) return foundMatch;
306
+ capturedValues.pop();
307
+ }
308
+ if (node.wildcardChild) {
309
+ return {
310
+ middleware: node.wildcardChild.middleware,
311
+ handler: node.wildcardChild.handler,
312
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
313
+ };
314
+ }
315
+ return null;
316
+ }
317
+ function zipParams(names, values) {
318
+ const params = {};
319
+ for (let i = 0; i < names.length; i++) {
320
+ params[names[i]] = values[i];
321
+ }
322
+ return params;
323
+ }
324
+ function safeDecode(segment) {
325
+ try {
326
+ return decodeURIComponent(segment);
327
+ } catch {
328
+ return segment;
329
+ }
330
+ }
331
+ function splitPath(path3) {
332
+ if (path3 === "" || path3 === "/") return [];
333
+ const withoutLeadingSlash = path3.startsWith("/") ? path3.slice(1) : path3;
334
+ return withoutLeadingSlash.split("/");
335
+ }
336
+
117
337
  // lib/utils/parseJSON.ts
118
338
  import { Buffer as Buffer3 } from "buffer";
119
339
  function isJSON(contentType) {
@@ -171,39 +391,40 @@ var parseJSON = (options = {}) => {
171
391
  // lib/utils/serveStatic.ts
172
392
  import fs from "fs";
173
393
  import path from "path";
174
- var MIME_TYPES = {
175
- html: "text/html",
176
- css: "text/css",
177
- js: "application/javascript",
178
- jpg: "image/jpeg",
179
- jpeg: "image/jpeg",
180
- png: "image/png",
181
- svg: "image/svg+xml",
182
- txt: "text/plain",
183
- eot: "application/vnd.ms-fontobject",
184
- otf: "font/otf",
185
- ttf: "font/ttf",
186
- woff: "font/woff",
187
- woff2: "font/woff2",
188
- gif: "image/gif",
189
- ico: "image/x-icon",
190
- json: "application/json",
191
- webmanifest: "application/manifest+json"
192
- };
193
- var serveStatic = (folderPath, newMimeTypes, options) => {
194
- if (newMimeTypes) {
195
- Object.assign(MIME_TYPES, newMimeTypes);
196
- }
394
+ var serveStatic = (folderPath, options) => {
197
395
  const prefix = options?.prefix ?? "";
396
+ const live = options?.live ?? false;
397
+ if (live) {
398
+ const resolvedFolder2 = path.resolve(folderPath);
399
+ const excludes2 = (options?.exclude ?? []).map((e) => path.join(resolvedFolder2, e));
400
+ return async function(req, res, next) {
401
+ const url = req.url;
402
+ if (typeof url !== "string") return next();
403
+ const pathname = url.split("?")[0];
404
+ const unprefixed = prefix ? pathname.slice(prefix.length) : pathname;
405
+ const filePath = path.join(resolvedFolder2, unprefixed);
406
+ const fileExtension = path.extname(filePath).slice(1);
407
+ const mime = MIME_TYPES[fileExtension];
408
+ if (!mime || !filePath.startsWith(resolvedFolder2)) return next();
409
+ if (excludes2.some((e) => filePath.startsWith(e))) return next();
410
+ const stat = await fs.promises.stat(filePath).catch(() => null);
411
+ if (stat?.isFile()) return res.sendFile(filePath, mime);
412
+ next();
413
+ };
414
+ }
415
+ const resolvedFolder = path.resolve(folderPath);
416
+ const excludes = (options?.exclude ?? []).map((e) => path.join(resolvedFolder, e));
198
417
  function processFolder(folderPath2, parentFolder) {
199
418
  const staticFiles = [];
200
419
  const files = fs.readdirSync(folderPath2);
201
420
  for (const file of files) {
202
421
  const fullPath = path.join(folderPath2, file);
203
422
  if (fs.statSync(fullPath).isDirectory()) {
423
+ if (excludes.some((e) => fullPath.startsWith(e))) continue;
204
424
  const subfolderFiles = processFolder(fullPath, parentFolder);
205
425
  staticFiles.push(...subfolderFiles);
206
426
  } else {
427
+ if (excludes.some((e) => fullPath.startsWith(e))) continue;
207
428
  const relativePath = path.relative(parentFolder, fullPath);
208
429
  const fileExtension = path.extname(file).slice(1);
209
430
  if (MIME_TYPES[fileExtension]) staticFiles.push("/" + relativePath);
@@ -236,47 +457,126 @@ var serveStatic = (folderPath, newMimeTypes, options) => {
236
457
  };
237
458
 
238
459
  // lib/utils/render.ts
239
- import fs2 from "fs/promises";
240
- function renderTemplate(templateStr, data) {
241
- let result = [];
242
- let currentIndex = 0;
243
- while (currentIndex < templateStr.length) {
244
- const startIdx = templateStr.indexOf("{{", currentIndex);
245
- if (startIdx === -1) {
246
- result.push(templateStr.slice(currentIndex));
247
- break;
460
+ import path2 from "path";
461
+ import { createReadStream } from "fs";
462
+ import { readFile } from "fs/promises";
463
+ import { Transform } from "stream";
464
+ import { pipeline as pipeline2 } from "stream/promises";
465
+ var MAX_PATTERN = 128;
466
+ function escapeHtml(value) {
467
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
468
+ }
469
+ var TemplateTransform = class _TemplateTransform extends Transform {
470
+ constructor(data, baseDir) {
471
+ super();
472
+ this.data = data;
473
+ this.baseDir = baseDir;
474
+ }
475
+ tail = "";
476
+ _transform(chunk, _, callback) {
477
+ const str = this.tail + chunk.toString("utf8");
478
+ if (str.length <= MAX_PATTERN) {
479
+ this.tail = str;
480
+ callback();
481
+ return;
248
482
  }
249
- result.push(templateStr.slice(currentIndex, startIdx));
250
- const endIdx = templateStr.indexOf("}}", startIdx);
251
- if (endIdx === -1) {
252
- result.push(templateStr.slice(startIdx));
253
- break;
483
+ let boundary = str.length - MAX_PATTERN;
484
+ for (const [opener, closer] of [
485
+ ["{{", "}}"],
486
+ ["<cpeak", ">"]
487
+ ]) {
488
+ const last = str.lastIndexOf(opener, boundary - 1);
489
+ if (last === -1) continue;
490
+ const closeIdx = str.indexOf(closer, last + opener.length);
491
+ if (closeIdx === -1 || closeIdx >= boundary)
492
+ boundary = Math.min(boundary, last);
254
493
  }
255
- const varName = templateStr.slice(startIdx + 2, endIdx).trim();
256
- const replacement = data[varName] !== void 0 ? data[varName] : "";
257
- result.push(replacement);
258
- currentIndex = endIdx + 2;
494
+ this.tail = str.slice(boundary);
495
+ const safe = str.slice(0, boundary);
496
+ if (safe)
497
+ this.process(safe).then(() => callback()).catch(callback);
498
+ else callback();
259
499
  }
260
- return result.join("");
261
- }
500
+ _flush(callback) {
501
+ if (this.tail)
502
+ this.process(this.tail).then(() => callback()).catch(callback);
503
+ else callback();
504
+ }
505
+ async process(str) {
506
+ const RE = /<cpeak\s+include="([^"]+)"\s*\/?>|<cpeak\s+html=\{([^}]+)\}\s*\/?>|\{\{([^}]+)\}\}/g;
507
+ let last = 0;
508
+ for (const match of str.matchAll(RE)) {
509
+ const idx = match.index;
510
+ if (idx > last) this.push(str.slice(last, idx));
511
+ const [, includeSrc, rawKey, escapedKey] = match;
512
+ if (includeSrc !== void 0) {
513
+ const includePath = path2.resolve(this.baseDir, includeSrc);
514
+ const content = await readFile(includePath, "utf8");
515
+ const chunks = [];
516
+ const nested = new _TemplateTransform(
517
+ this.data,
518
+ path2.dirname(includePath)
519
+ );
520
+ await new Promise((resolve, reject) => {
521
+ nested.on("data", (c) => chunks.push(c));
522
+ nested.on("end", resolve);
523
+ nested.on("error", reject);
524
+ nested.end(Buffer.from(content, "utf8"));
525
+ });
526
+ this.push(Buffer.concat(chunks));
527
+ } else if (rawKey !== void 0) {
528
+ const val = this.data[rawKey.trim()];
529
+ if (val !== void 0) this.push(String(val));
530
+ } else {
531
+ const val = this.data[escapedKey.trim()];
532
+ if (val !== void 0) this.push(escapeHtml(String(val)));
533
+ }
534
+ last = idx + match[0].length;
535
+ }
536
+ if (last < str.length) this.push(str.slice(last));
537
+ }
538
+ };
262
539
  var render = () => {
263
540
  return function(req, res, next) {
264
- res.render = async (path2, data, mime) => {
541
+ res.render = async (filePath, data, mime) => {
542
+ if (res.headersSent) return;
265
543
  if (!mime) {
544
+ const dotIndex = filePath.lastIndexOf(".");
545
+ const fileExtension = dotIndex >= 0 ? filePath.slice(dotIndex + 1) : "";
546
+ mime = MIME_TYPES[fileExtension];
547
+ if (!mime) {
548
+ throw frameworkError(
549
+ `MIME type is missing for "${filePath}". Pass it as the third argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
550
+ res.render,
551
+ "CPEAK_ERR_MISSING_MIME" /* MISSING_MIME */
552
+ );
553
+ }
554
+ }
555
+ const resolved = path2.resolve(filePath);
556
+ try {
557
+ if (res._compression) {
558
+ const readStream = createReadStream(resolved);
559
+ const transform = new TemplateTransform(data, path2.dirname(resolved));
560
+ pipeline2(readStream, transform).catch(() => {
561
+ });
562
+ await compressAndSend(res, mime, transform, res._compression);
563
+ return;
564
+ }
565
+ res.setHeader("Content-Type", mime);
566
+ await pipeline2(
567
+ createReadStream(resolved),
568
+ new TemplateTransform(data, path2.dirname(resolved)),
569
+ res
570
+ );
571
+ } catch (err) {
266
572
  throw frameworkError(
267
- `MIME type is missing. You called res.render("${path2}", ...) but forgot to provide the third "mime" argument.`,
268
- res.render
573
+ `Failed to render "${filePath}." Error: ${err}`,
574
+ res.render,
575
+ "CPEAK_ERR_RENDER_FAIL" /* RENDER_FAIL */,
576
+ void 0,
577
+ isClientDisconnect(err)
269
578
  );
270
579
  }
271
- let fileStr = await fs2.readFile(path2, "utf-8");
272
- const finalStr = renderTemplate(fileStr, data);
273
- const config = res.socket?.server?._cpeakCompression;
274
- if (config) {
275
- await compressAndSend(res, mime, finalStr, config);
276
- return;
277
- }
278
- res.setHeader("Content-Type", mime);
279
- res.end(finalStr);
280
580
  };
281
581
  next();
282
582
  };
@@ -468,8 +768,8 @@ function parseRawCookies(header) {
468
768
  }
469
769
  function buildSetCookieHeader(name, value, options) {
470
770
  const parts = [`${name}=${encodeURIComponent(value)}`];
471
- const path2 = options.path ?? "/";
472
- parts.push(`Path=${path2}`);
771
+ const path3 = options.path ?? "/";
772
+ parts.push(`Path=${path3}`);
473
773
  if (options.domain) parts.push(`Domain=${options.domain}`);
474
774
  if (options.maxAge !== void 0)
475
775
  parts.push(`Max-Age=${Math.floor(options.maxAge / 1e3)}`);
@@ -603,28 +903,6 @@ var cors = (options = {}) => {
603
903
  };
604
904
 
605
905
  // lib/index.ts
606
- function frameworkError(message, skipFn, code, status) {
607
- const err = new Error(message);
608
- Error.captureStackTrace(err, skipFn);
609
- err.cpeak_err = true;
610
- if (code) err.code = code;
611
- if (status) err.status = status;
612
- return err;
613
- }
614
- var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
615
- ErrorCode2["MISSING_MIME"] = "CPEAK_ERR_MISSING_MIME";
616
- ErrorCode2["FILE_NOT_FOUND"] = "CPEAK_ERR_FILE_NOT_FOUND";
617
- ErrorCode2["NOT_A_FILE"] = "CPEAK_ERR_NOT_A_FILE";
618
- ErrorCode2["SEND_FILE_FAIL"] = "CPEAK_ERR_SEND_FILE_FAIL";
619
- ErrorCode2["INVALID_JSON"] = "CPEAK_ERR_INVALID_JSON";
620
- ErrorCode2["PAYLOAD_TOO_LARGE"] = "CPEAK_ERR_PAYLOAD_TOO_LARGE";
621
- ErrorCode2["WEAK_SECRET"] = "CPEAK_ERR_WEAK_SECRET";
622
- ErrorCode2["COMPRESSION_NOT_ENABLED"] = "CPEAK_ERR_COMPRESSION_NOT_ENABLED";
623
- return ErrorCode2;
624
- })(ErrorCode || {});
625
- function compressionConfigFor(res) {
626
- return res.socket?.server?._cpeakCompression;
627
- }
628
906
  var CpeakIncomingMessage = class extends http.IncomingMessage {
629
907
  // We define body and params here for better V8 optimization (not changing the shape of the object at runtime)
630
908
  body = void 0;
@@ -647,44 +925,59 @@ var CpeakIncomingMessage = class extends http.IncomingMessage {
647
925
  }
648
926
  };
649
927
  var CpeakServerResponse = class extends http.ServerResponse {
928
+ // Set per-request from the Cpeak instance. Undefined when compression isn't enabled.
929
+ _compression;
650
930
  // Send a file back to the client
651
- async sendFile(path2, mime) {
931
+ async sendFile(path3, mime) {
932
+ if (this.headersSent) return;
652
933
  if (!mime) {
653
- throw frameworkError(
654
- 'MIME type is missing. Use res.sendFile(path, "mime-type").',
655
- this.sendFile,
656
- "CPEAK_ERR_MISSING_MIME" /* MISSING_MIME */
657
- );
934
+ const dotIndex = path3.lastIndexOf(".");
935
+ const fileExtension = dotIndex >= 0 ? path3.slice(dotIndex + 1) : "";
936
+ mime = MIME_TYPES[fileExtension];
937
+ if (!mime) {
938
+ throw frameworkError(
939
+ `MIME type is missing for "${path3}". Pass it as the second argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
940
+ this.sendFile,
941
+ "CPEAK_ERR_MISSING_MIME" /* MISSING_MIME */
942
+ );
943
+ }
658
944
  }
659
945
  try {
660
- const stat = await fs3.stat(path2);
946
+ const stat = await fs2.stat(path3);
661
947
  if (!stat.isFile()) {
662
948
  throw frameworkError(
663
- `Not a file: ${path2}`,
949
+ `Not a file: ${path3}`,
664
950
  this.sendFile,
665
951
  "CPEAK_ERR_NOT_A_FILE" /* NOT_A_FILE */
666
952
  );
667
953
  }
668
- const config = compressionConfigFor(this);
669
- if (config) {
670
- await compressAndSend(this, mime, createReadStream(path2), config, stat.size);
954
+ if (this._compression) {
955
+ await compressAndSend(
956
+ this,
957
+ mime,
958
+ createReadStream2(path3),
959
+ this._compression,
960
+ stat.size
961
+ );
671
962
  return;
672
963
  }
673
964
  this.setHeader("Content-Type", mime);
674
965
  this.setHeader("Content-Length", String(stat.size));
675
- await pipeline2(createReadStream(path2), this);
966
+ await pipeline3(createReadStream2(path3), this);
676
967
  } catch (err) {
677
968
  if (err?.code === "ENOENT") {
678
969
  throw frameworkError(
679
- `File not found: ${path2}`,
970
+ `File not found: ${path3}`,
680
971
  this.sendFile,
681
972
  "CPEAK_ERR_FILE_NOT_FOUND" /* FILE_NOT_FOUND */
682
973
  );
683
974
  }
684
975
  throw frameworkError(
685
- `Failed to send file: ${path2}`,
976
+ `Failed to send file: ${path3}`,
686
977
  this.sendFile,
687
- "CPEAK_ERR_SEND_FILE_FAIL" /* SEND_FILE_FAIL */
978
+ "CPEAK_ERR_SEND_FILE_FAIL" /* SEND_FILE_FAIL */,
979
+ void 0,
980
+ isClientDisconnect(err)
688
981
  );
689
982
  }
690
983
  }
@@ -704,84 +997,104 @@ var CpeakServerResponse = class extends http.ServerResponse {
704
997
  this.writeHead(302, { Location: location });
705
998
  this.end();
706
999
  }
707
- // Send a json data back to the client. Sync hot path when compression is
708
- // off no Promise allocation, no microtask. Branches into compressAndSend
709
- // (async) when compression was enabled at cpeak() construction.
1000
+ // Send a json data back to the client.
1001
+ // This is only good for bodies that their size is less than the highWaterMark value.
710
1002
  json(data) {
1003
+ if (this.headersSent) return Promise.resolve();
711
1004
  const body = JSON.stringify(data);
712
- const config = compressionConfigFor(this);
713
- if (config) {
714
- return compressAndSend(this, "application/json", body, config);
1005
+ if (this._compression) {
1006
+ return compressAndSend(this, "application/json", body, this._compression);
715
1007
  }
716
1008
  this.setHeader("Content-Type", "application/json");
717
1009
  this.end(body);
1010
+ return Promise.resolve();
718
1011
  }
719
- // Explicit compression entry point. Throws if compression wasn't configured —
720
- // the developer asked to compress but the framework was never told to.
1012
+ render() {
1013
+ throw frameworkError(
1014
+ "render middleware not registered. Add render() via app.beforeEach(render()) to use res.render.",
1015
+ this.render,
1016
+ "CPEAK_ERR_RENDER_NOT_ENABLED" /* RENDER_NOT_ENABLED */
1017
+ );
1018
+ }
1019
+ // Explicit compression entry point. A developer can use this in any custom handler to compress arbitrary responses
721
1020
  compress(mime, body, size) {
722
- const config = compressionConfigFor(this);
723
- if (!config) {
1021
+ if (this.headersSent) return Promise.resolve();
1022
+ if (!this._compression) {
724
1023
  throw frameworkError(
725
1024
  "compression is not enabled. Pass `compression` to cpeak({ compression: true | { ... } }) to use res.compress.",
726
1025
  this.compress,
727
1026
  "CPEAK_ERR_COMPRESSION_NOT_ENABLED" /* COMPRESSION_NOT_ENABLED */
728
1027
  );
729
1028
  }
730
- return compressAndSend(this, mime, body, config, size);
1029
+ return compressAndSend(this, mime, body, this._compression, size);
731
1030
  }
732
1031
  };
733
1032
  var Cpeak = class {
734
1033
  #server;
735
- #routes;
1034
+ #router;
736
1035
  #middleware;
737
1036
  #handleErr;
1037
+ #fallback;
1038
+ #compression;
738
1039
  constructor(options = {}) {
739
1040
  this.#server = http.createServer({
740
1041
  IncomingMessage: CpeakIncomingMessage,
741
1042
  ServerResponse: CpeakServerResponse
742
1043
  });
743
- this.#routes = {};
1044
+ this.#router = new Router();
744
1045
  this.#middleware = [];
745
1046
  if (options.compression) {
746
- this.#server._cpeakCompression = resolveCompressionOptions(
747
- options.compression
748
- );
1047
+ this.#compression = resolveCompressionOptions(options.compression);
749
1048
  }
1049
+ if (options.mimeTypes) Object.assign(MIME_TYPES, options.mimeTypes);
750
1050
  this.#server.on(
751
1051
  "request",
752
1052
  async (req, res) => {
1053
+ res._compression = this.#compression;
753
1054
  const qIndex = req.url?.indexOf("?");
754
1055
  const urlWithoutQueries = qIndex === -1 ? req.url || "" : req.url?.substring(0, qIndex);
755
- const dispatchError = (error) => {
1056
+ const dispatchError = async (error) => {
756
1057
  if (res.headersSent) {
757
1058
  req.socket?.destroy();
758
- return;
1059
+ } else {
1060
+ res.setHeader("Connection", "close");
1061
+ }
1062
+ if (isClientDisconnect(error) && !error.clientDisconnect) {
1063
+ error.clientDisconnect = true;
1064
+ }
1065
+ try {
1066
+ await this.#handleErr?.(error, req, res);
1067
+ } catch (handlerFailure) {
1068
+ console.error(
1069
+ "[cpeak] handleErr failed while processing:",
1070
+ error,
1071
+ "\nReason:",
1072
+ handlerFailure
1073
+ );
1074
+ if (!res.headersSent) {
1075
+ try {
1076
+ res.statusCode = 500;
1077
+ res.end();
1078
+ } catch {
1079
+ }
1080
+ }
759
1081
  }
760
- res.setHeader("Connection", "close");
761
- this.#handleErr?.(error, req, res);
762
1082
  };
763
1083
  const runHandler = async (req2, res2, middleware, cb, index) => {
764
1084
  if (index === middleware.length) {
765
1085
  try {
766
- await cb(req2, res2, dispatchError);
1086
+ await cb(req2, res2);
767
1087
  } catch (error) {
768
1088
  dispatchError(error);
769
1089
  }
770
1090
  } else {
771
1091
  try {
772
- await middleware[index](
773
- req2,
774
- res2,
775
- // The next function
776
- async (error) => {
777
- if (error) {
778
- return dispatchError(error);
779
- }
780
- await runHandler(req2, res2, middleware, cb, index + 1);
781
- },
782
- // Error handler for a route middleware
783
- dispatchError
784
- );
1092
+ await middleware[index](req2, res2, async (error) => {
1093
+ if (error) {
1094
+ return dispatchError(error);
1095
+ }
1096
+ await runHandler(req2, res2, middleware, cb, index + 1);
1097
+ });
785
1098
  } catch (error) {
786
1099
  dispatchError(error);
787
1100
  }
@@ -789,25 +1102,25 @@ var Cpeak = class {
789
1102
  };
790
1103
  const runMiddleware = async (req2, res2, middleware, index) => {
791
1104
  if (index === middleware.length) {
792
- const routes = this.#routes[req2.method?.toLowerCase() || ""];
793
- if (routes && typeof routes[Symbol.iterator] === "function")
794
- for (const route of routes) {
795
- const match = urlWithoutQueries?.match(route.regex);
796
- if (match) {
797
- const pathVariables = this.#extractPathVariables(
798
- route.path,
799
- match
800
- );
801
- req2.params = pathVariables;
802
- return await runHandler(
803
- req2,
804
- res2,
805
- route.middleware,
806
- route.cb,
807
- 0
808
- );
809
- }
1105
+ const method = req2.method?.toLowerCase() || "";
1106
+ const found = this.#router.find(method, urlWithoutQueries || "");
1107
+ if (found) {
1108
+ req2.params = found.params;
1109
+ return await runHandler(
1110
+ req2,
1111
+ res2,
1112
+ found.middleware,
1113
+ found.handler,
1114
+ 0
1115
+ );
1116
+ }
1117
+ if (this.#fallback) {
1118
+ try {
1119
+ return await this.#fallback(req2, res2);
1120
+ } catch (error) {
1121
+ return dispatchError(error);
810
1122
  }
1123
+ }
811
1124
  return res2.status(404).json({ error: `Cannot ${req2.method} ${urlWithoutQueries}` });
812
1125
  } else {
813
1126
  try {
@@ -826,15 +1139,13 @@ var Cpeak = class {
826
1139
  }
827
1140
  );
828
1141
  }
829
- route(method, path2, ...args) {
830
- if (!this.#routes[method]) this.#routes[method] = [];
1142
+ route(method, path3, ...args) {
831
1143
  const cb = args.pop();
832
1144
  if (!cb || typeof cb !== "function") {
833
1145
  throw new Error("Route definition must include a handler");
834
1146
  }
835
1147
  const middleware = args.flat();
836
- const regex = this.#pathToRegex(path2);
837
- this.#routes[method].push({ path: path2, regex, middleware, cb });
1148
+ this.#router.add(method, path3, middleware, cb);
838
1149
  }
839
1150
  beforeEach(cb) {
840
1151
  this.#middleware.push(cb);
@@ -842,31 +1153,29 @@ var Cpeak = class {
842
1153
  handleErr(cb) {
843
1154
  this.#handleErr = cb;
844
1155
  }
845
- listen(port, cb) {
846
- return this.#server.listen(port, cb);
1156
+ // This will handle any request that doesn't match any of the routes and middleware functions
1157
+ fallback(cb) {
1158
+ if (this.#fallback) {
1159
+ throw frameworkError(
1160
+ "Fallback handler is already registered. Only one fallback can be set per app.",
1161
+ this.fallback,
1162
+ "CPEAK_ERR_DUPLICATE_FALLBACK" /* DUPLICATE_FALLBACK */
1163
+ );
1164
+ }
1165
+ this.#fallback = cb;
1166
+ }
1167
+ listen(...args) {
1168
+ return this.#server.listen(...args);
847
1169
  }
848
1170
  address() {
849
1171
  return this.#server.address();
850
1172
  }
851
1173
  close(cb) {
852
- this.#server.close(cb);
853
- }
854
- // ------------------------------
855
- // PRIVATE METHODS:
856
- // ------------------------------
857
- #pathToRegex(path2) {
858
- const regexString = "^" + path2.replace(/:\w+/g, "([^/]+)").replace(/\*/g, ".*") + "$";
859
- return new RegExp(regexString);
860
- }
861
- #extractPathVariables(path2, match) {
862
- const paramNames = (path2.match(/:\w+/g) || []).map(
863
- (param) => param.slice(1)
864
- );
865
- const params = {};
866
- paramNames.forEach((name, index) => {
867
- params[name] = match[index + 1];
868
- });
869
- return params;
1174
+ return this.#server.close(cb);
1175
+ }
1176
+ // A getter for developers who want to access the underlying http server instance for advanced use cases that aren't covered by Cpeak
1177
+ get server() {
1178
+ return this.#server;
870
1179
  }
871
1180
  };
872
1181
  function cpeak(options) {
@@ -883,6 +1192,7 @@ export {
883
1192
  cpeak as default,
884
1193
  frameworkError,
885
1194
  hashPassword,
1195
+ isClientDisconnect,
886
1196
  parseJSON,
887
1197
  render,
888
1198
  serveStatic,