cpeak 2.7.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.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import fs3 from "fs/promises";
4
4
  import { createReadStream } from "fs";
5
5
  import { pipeline as pipeline2 } 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,204 @@ 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
+ webmanifest: "application/manifest+json"
136
+ };
137
+
138
+ // lib/internal/errors.ts
139
+ function frameworkError(message, skipFn, code, status) {
140
+ const err = new Error(message);
141
+ Error.captureStackTrace(err, skipFn);
142
+ err.cpeak_err = true;
143
+ if (code) err.code = code;
144
+ if (status) err.status = status;
145
+ return err;
146
+ }
147
+ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
148
+ ErrorCode2["MISSING_MIME"] = "CPEAK_ERR_MISSING_MIME";
149
+ ErrorCode2["FILE_NOT_FOUND"] = "CPEAK_ERR_FILE_NOT_FOUND";
150
+ ErrorCode2["NOT_A_FILE"] = "CPEAK_ERR_NOT_A_FILE";
151
+ ErrorCode2["SEND_FILE_FAIL"] = "CPEAK_ERR_SEND_FILE_FAIL";
152
+ ErrorCode2["INVALID_JSON"] = "CPEAK_ERR_INVALID_JSON";
153
+ ErrorCode2["PAYLOAD_TOO_LARGE"] = "CPEAK_ERR_PAYLOAD_TOO_LARGE";
154
+ ErrorCode2["WEAK_SECRET"] = "CPEAK_ERR_WEAK_SECRET";
155
+ ErrorCode2["COMPRESSION_NOT_ENABLED"] = "CPEAK_ERR_COMPRESSION_NOT_ENABLED";
156
+ ErrorCode2["DUPLICATE_ROUTE"] = "CPEAK_ERR_DUPLICATE_ROUTE";
157
+ ErrorCode2["INVALID_ROUTE"] = "CPEAK_ERR_INVALID_ROUTE";
158
+ return ErrorCode2;
159
+ })(ErrorCode || {});
160
+
161
+ // lib/internal/router.ts
162
+ function createNode() {
163
+ return { staticChildren: /* @__PURE__ */ new Map() };
164
+ }
165
+ var Router = class {
166
+ #treesByMethod = /* @__PURE__ */ new Map();
167
+ add(method, path2, middleware, handler) {
168
+ const methodKey = method.toLowerCase();
169
+ let root = this.#treesByMethod.get(methodKey);
170
+ if (!root) {
171
+ root = createNode();
172
+ this.#treesByMethod.set(methodKey, root);
173
+ }
174
+ const segments = splitPath(path2);
175
+ const paramNames = [];
176
+ let currentNode = root;
177
+ for (let i = 0; i < segments.length; i++) {
178
+ const segment = segments[i];
179
+ const isLastSegment = i === segments.length - 1;
180
+ if (segment.length > 1 && segment.startsWith("*")) {
181
+ throw frameworkError(
182
+ `Invalid route "${path2}": named wildcards (e.g. "*name") are not supported. Use a plain "*" at the end of the path.`,
183
+ this.add,
184
+ "CPEAK_ERR_INVALID_ROUTE" /* INVALID_ROUTE */
185
+ );
186
+ }
187
+ if (segment === "*") {
188
+ if (!isLastSegment) {
189
+ throw frameworkError(
190
+ `Invalid route "${path2}": "*" is only allowed as the final path segment.`,
191
+ this.add,
192
+ "CPEAK_ERR_INVALID_ROUTE" /* INVALID_ROUTE */
193
+ );
194
+ }
195
+ if (currentNode.wildcardChild) {
196
+ throw frameworkError(
197
+ `Duplicate route: ${method.toUpperCase()} ${path2}`,
198
+ this.add,
199
+ "CPEAK_ERR_DUPLICATE_ROUTE" /* DUPLICATE_ROUTE */
200
+ );
201
+ }
202
+ currentNode.wildcardChild = { handler, middleware, paramNames };
203
+ return;
204
+ }
205
+ if (segment.startsWith(":")) {
206
+ const paramName = segment.slice(1);
207
+ if (!paramName) {
208
+ throw frameworkError(
209
+ `Invalid route "${path2}": empty parameter name.`,
210
+ this.add,
211
+ "CPEAK_ERR_INVALID_ROUTE" /* INVALID_ROUTE */
212
+ );
213
+ }
214
+ paramNames.push(paramName);
215
+ if (!currentNode.paramChild) {
216
+ currentNode.paramChild = createNode();
217
+ }
218
+ currentNode = currentNode.paramChild;
219
+ continue;
220
+ }
221
+ let staticChild = currentNode.staticChildren.get(segment);
222
+ if (!staticChild) {
223
+ staticChild = createNode();
224
+ currentNode.staticChildren.set(segment, staticChild);
225
+ }
226
+ currentNode = staticChild;
227
+ }
228
+ if (currentNode.handler) {
229
+ throw frameworkError(
230
+ `Duplicate route: ${method.toUpperCase()} ${path2}`,
231
+ this.add,
232
+ "CPEAK_ERR_DUPLICATE_ROUTE" /* DUPLICATE_ROUTE */
233
+ );
234
+ }
235
+ currentNode.handler = handler;
236
+ currentNode.middleware = middleware;
237
+ currentNode.paramNames = paramNames;
238
+ }
239
+ find(method, path2) {
240
+ const root = this.#treesByMethod.get(method.toLowerCase());
241
+ if (!root) return null;
242
+ const segments = splitPath(path2);
243
+ return matchSegments(root, segments, 0, []);
244
+ }
245
+ };
246
+ function matchSegments(node, segments, segmentIndex, capturedValues) {
247
+ if (segmentIndex === segments.length) {
248
+ if (node.handler) {
249
+ return {
250
+ middleware: node.middleware,
251
+ handler: node.handler,
252
+ params: zipParams(node.paramNames, capturedValues)
253
+ };
254
+ }
255
+ if (node.wildcardChild) {
256
+ return {
257
+ middleware: node.wildcardChild.middleware,
258
+ handler: node.wildcardChild.handler,
259
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
260
+ };
261
+ }
262
+ return null;
263
+ }
264
+ const segment = segments[segmentIndex];
265
+ const staticChild = node.staticChildren.get(segment);
266
+ if (staticChild) {
267
+ const foundMatch = matchSegments(
268
+ staticChild,
269
+ segments,
270
+ segmentIndex + 1,
271
+ capturedValues
272
+ );
273
+ if (foundMatch) return foundMatch;
274
+ }
275
+ if (node.paramChild) {
276
+ capturedValues.push(safeDecode(segment));
277
+ const foundMatch = matchSegments(
278
+ node.paramChild,
279
+ segments,
280
+ segmentIndex + 1,
281
+ capturedValues
282
+ );
283
+ if (foundMatch) return foundMatch;
284
+ capturedValues.pop();
285
+ }
286
+ if (node.wildcardChild) {
287
+ return {
288
+ middleware: node.wildcardChild.middleware,
289
+ handler: node.wildcardChild.handler,
290
+ params: zipParams(node.wildcardChild.paramNames, capturedValues)
291
+ };
292
+ }
293
+ return null;
294
+ }
295
+ function zipParams(names, values) {
296
+ const params = {};
297
+ for (let i = 0; i < names.length; i++) {
298
+ params[names[i]] = values[i];
299
+ }
300
+ return params;
301
+ }
302
+ function safeDecode(segment) {
303
+ try {
304
+ return decodeURIComponent(segment);
305
+ } catch {
306
+ return segment;
307
+ }
308
+ }
309
+ function splitPath(path2) {
310
+ if (path2 === "" || path2 === "/") return [];
311
+ const withoutLeadingSlash = path2.startsWith("/") ? path2.slice(1) : path2;
312
+ return withoutLeadingSlash.split("/");
313
+ }
314
+
117
315
  // lib/utils/parseJSON.ts
118
316
  import { Buffer as Buffer3 } from "buffer";
119
317
  function isJSON(contentType) {
@@ -171,30 +369,25 @@ var parseJSON = (options = {}) => {
171
369
  // lib/utils/serveStatic.ts
172
370
  import fs from "fs";
173
371
  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
- }
372
+ var serveStatic = (folderPath, options) => {
197
373
  const prefix = options?.prefix ?? "";
374
+ const live = options?.live ?? false;
375
+ if (live) {
376
+ const resolvedFolder = path.resolve(folderPath);
377
+ return async function(req, res, next) {
378
+ const url = req.url;
379
+ if (typeof url !== "string") return next();
380
+ const pathname = url.split("?")[0];
381
+ const unprefixed = prefix ? pathname.slice(prefix.length) : pathname;
382
+ const filePath = path.join(resolvedFolder, unprefixed);
383
+ const fileExtension = path.extname(filePath).slice(1);
384
+ const mime = MIME_TYPES[fileExtension];
385
+ if (!mime || !filePath.startsWith(resolvedFolder)) return next();
386
+ const stat = await fs.promises.stat(filePath).catch(() => null);
387
+ if (stat?.isFile()) return res.sendFile(filePath, mime);
388
+ next();
389
+ };
390
+ }
198
391
  function processFolder(folderPath2, parentFolder) {
199
392
  const staticFiles = [];
200
393
  const files = fs.readdirSync(folderPath2);
@@ -263,16 +456,20 @@ var render = () => {
263
456
  return function(req, res, next) {
264
457
  res.render = async (path2, data, mime) => {
265
458
  if (!mime) {
266
- throw frameworkError(
267
- `MIME type is missing. You called res.render("${path2}", ...) but forgot to provide the third "mime" argument.`,
268
- res.render
269
- );
459
+ const dotIndex = path2.lastIndexOf(".");
460
+ const fileExtension = dotIndex >= 0 ? path2.slice(dotIndex + 1) : "";
461
+ mime = MIME_TYPES[fileExtension];
462
+ if (!mime) {
463
+ throw frameworkError(
464
+ `MIME type is missing for "${path2}". Pass it as the third argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
465
+ res.render
466
+ );
467
+ }
270
468
  }
271
469
  let fileStr = await fs2.readFile(path2, "utf-8");
272
470
  const finalStr = renderTemplate(fileStr, data);
273
- const config = res.socket?.server?._cpeakCompression;
274
- if (config) {
275
- await compressAndSend(res, mime, finalStr, config);
471
+ if (res._compression) {
472
+ await compressAndSend(res, mime, finalStr, res._compression);
276
473
  return;
277
474
  }
278
475
  res.setHeader("Content-Type", mime);
@@ -603,28 +800,6 @@ var cors = (options = {}) => {
603
800
  };
604
801
 
605
802
  // 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
803
  var CpeakIncomingMessage = class extends http.IncomingMessage {
629
804
  // We define body and params here for better V8 optimization (not changing the shape of the object at runtime)
630
805
  body = void 0;
@@ -647,14 +822,21 @@ var CpeakIncomingMessage = class extends http.IncomingMessage {
647
822
  }
648
823
  };
649
824
  var CpeakServerResponse = class extends http.ServerResponse {
825
+ // Set per-request from the Cpeak instance. Undefined when compression isn't enabled.
826
+ _compression;
650
827
  // Send a file back to the client
651
828
  async sendFile(path2, mime) {
652
829
  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
- );
830
+ const dotIndex = path2.lastIndexOf(".");
831
+ const fileExtension = dotIndex >= 0 ? path2.slice(dotIndex + 1) : "";
832
+ mime = MIME_TYPES[fileExtension];
833
+ if (!mime) {
834
+ throw frameworkError(
835
+ `MIME type is missing for "${path2}". Pass it as the second argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
836
+ this.sendFile,
837
+ "CPEAK_ERR_MISSING_MIME" /* MISSING_MIME */
838
+ );
839
+ }
658
840
  }
659
841
  try {
660
842
  const stat = await fs3.stat(path2);
@@ -665,9 +847,14 @@ var CpeakServerResponse = class extends http.ServerResponse {
665
847
  "CPEAK_ERR_NOT_A_FILE" /* NOT_A_FILE */
666
848
  );
667
849
  }
668
- const config = compressionConfigFor(this);
669
- if (config) {
670
- await compressAndSend(this, mime, createReadStream(path2), config, stat.size);
850
+ if (this._compression) {
851
+ await compressAndSend(
852
+ this,
853
+ mime,
854
+ createReadStream(path2),
855
+ this._compression,
856
+ stat.size
857
+ );
671
858
  return;
672
859
  }
673
860
  this.setHeader("Content-Type", mime);
@@ -704,84 +891,91 @@ var CpeakServerResponse = class extends http.ServerResponse {
704
891
  this.writeHead(302, { Location: location });
705
892
  this.end();
706
893
  }
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.
894
+ // Send a json data back to the client.
895
+ // This is only good for bodies that their size is less than the highWaterMark value.
710
896
  json(data) {
711
897
  const body = JSON.stringify(data);
712
- const config = compressionConfigFor(this);
713
- if (config) {
714
- return compressAndSend(this, "application/json", body, config);
898
+ if (this._compression) {
899
+ return compressAndSend(this, "application/json", body, this._compression);
715
900
  }
716
901
  this.setHeader("Content-Type", "application/json");
717
902
  this.end(body);
903
+ return Promise.resolve();
718
904
  }
719
- // Explicit compression entry point. Throws if compression wasn't configured
720
- // the developer asked to compress but the framework was never told to.
905
+ // Explicit compression entry point. A developer can use this in any custom handler to compress arbitrary responses
721
906
  compress(mime, body, size) {
722
- const config = compressionConfigFor(this);
723
- if (!config) {
907
+ if (!this._compression) {
724
908
  throw frameworkError(
725
909
  "compression is not enabled. Pass `compression` to cpeak({ compression: true | { ... } }) to use res.compress.",
726
910
  this.compress,
727
911
  "CPEAK_ERR_COMPRESSION_NOT_ENABLED" /* COMPRESSION_NOT_ENABLED */
728
912
  );
729
913
  }
730
- return compressAndSend(this, mime, body, config, size);
914
+ return compressAndSend(this, mime, body, this._compression, size);
731
915
  }
732
916
  };
733
917
  var Cpeak = class {
734
918
  #server;
735
- #routes;
919
+ #router;
736
920
  #middleware;
737
921
  #handleErr;
922
+ #compression;
738
923
  constructor(options = {}) {
739
924
  this.#server = http.createServer({
740
925
  IncomingMessage: CpeakIncomingMessage,
741
926
  ServerResponse: CpeakServerResponse
742
927
  });
743
- this.#routes = {};
928
+ this.#router = new Router();
744
929
  this.#middleware = [];
745
930
  if (options.compression) {
746
- this.#server._cpeakCompression = resolveCompressionOptions(
747
- options.compression
748
- );
931
+ this.#compression = resolveCompressionOptions(options.compression);
749
932
  }
933
+ if (options.mimeTypes) Object.assign(MIME_TYPES, options.mimeTypes);
750
934
  this.#server.on(
751
935
  "request",
752
936
  async (req, res) => {
937
+ res._compression = this.#compression;
753
938
  const qIndex = req.url?.indexOf("?");
754
939
  const urlWithoutQueries = qIndex === -1 ? req.url || "" : req.url?.substring(0, qIndex);
755
- const dispatchError = (error) => {
940
+ const dispatchError = async (error) => {
756
941
  if (res.headersSent) {
757
942
  req.socket?.destroy();
758
943
  return;
759
944
  }
760
945
  res.setHeader("Connection", "close");
761
- this.#handleErr?.(error, req, res);
946
+ try {
947
+ await this.#handleErr?.(error, req, res);
948
+ } catch (handlerFailure) {
949
+ console.error(
950
+ "[cpeak] handleErr failed while processing:",
951
+ error,
952
+ "\nReason:",
953
+ handlerFailure
954
+ );
955
+ if (!res.headersSent) {
956
+ try {
957
+ res.statusCode = 500;
958
+ res.end();
959
+ } catch {
960
+ }
961
+ }
962
+ }
762
963
  };
763
964
  const runHandler = async (req2, res2, middleware, cb, index) => {
764
965
  if (index === middleware.length) {
765
966
  try {
766
- await cb(req2, res2, dispatchError);
967
+ await cb(req2, res2);
767
968
  } catch (error) {
768
969
  dispatchError(error);
769
970
  }
770
971
  } else {
771
972
  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
- );
973
+ await middleware[index](req2, res2, async (error) => {
974
+ if (error) {
975
+ return dispatchError(error);
976
+ }
977
+ await runHandler(req2, res2, middleware, cb, index + 1);
978
+ });
785
979
  } catch (error) {
786
980
  dispatchError(error);
787
981
  }
@@ -789,25 +983,18 @@ var Cpeak = class {
789
983
  };
790
984
  const runMiddleware = async (req2, res2, middleware, index) => {
791
985
  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
- }
810
- }
986
+ const method = req2.method?.toLowerCase() || "";
987
+ const found = this.#router.find(method, urlWithoutQueries || "");
988
+ if (found) {
989
+ req2.params = found.params;
990
+ return await runHandler(
991
+ req2,
992
+ res2,
993
+ found.middleware,
994
+ found.handler,
995
+ 0
996
+ );
997
+ }
811
998
  return res2.status(404).json({ error: `Cannot ${req2.method} ${urlWithoutQueries}` });
812
999
  } else {
813
1000
  try {
@@ -827,14 +1014,12 @@ var Cpeak = class {
827
1014
  );
828
1015
  }
829
1016
  route(method, path2, ...args) {
830
- if (!this.#routes[method]) this.#routes[method] = [];
831
1017
  const cb = args.pop();
832
1018
  if (!cb || typeof cb !== "function") {
833
1019
  throw new Error("Route definition must include a handler");
834
1020
  }
835
1021
  const middleware = args.flat();
836
- const regex = this.#pathToRegex(path2);
837
- this.#routes[method].push({ path: path2, regex, middleware, cb });
1022
+ this.#router.add(method, path2, middleware, cb);
838
1023
  }
839
1024
  beforeEach(cb) {
840
1025
  this.#middleware.push(cb);
@@ -842,31 +1027,18 @@ var Cpeak = class {
842
1027
  handleErr(cb) {
843
1028
  this.#handleErr = cb;
844
1029
  }
845
- listen(port, cb) {
846
- return this.#server.listen(port, cb);
1030
+ listen(...args) {
1031
+ return this.#server.listen(...args);
847
1032
  }
848
1033
  address() {
849
1034
  return this.#server.address();
850
1035
  }
851
1036
  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;
1037
+ return this.#server.close(cb);
1038
+ }
1039
+ // A getter for developers who want to access the underlying http server instance for advanced use cases that aren't covered by Cpeak
1040
+ get server() {
1041
+ return this.#server;
870
1042
  }
871
1043
  };
872
1044
  function cpeak(options) {