cpeak 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -67
- package/dist/index.d.ts +73 -42
- package/dist/index.js +503 -118
- package/dist/index.js.map +1 -1
- package/lib/index.ts +177 -123
- package/lib/internal/compression.ts +180 -0
- package/lib/internal/errors.ts +35 -0
- package/lib/internal/mimeTypes.ts +22 -0
- package/lib/internal/router.ts +259 -0
- package/lib/internal/types.ts +10 -0
- package/lib/types.ts +31 -20
- package/lib/utils/auth.ts +1 -23
- package/lib/utils/cookieParser.ts +1 -11
- package/lib/utils/cors.ts +109 -0
- package/lib/utils/index.ts +3 -4
- package/lib/utils/render.ts +18 -6
- package/lib/utils/serveStatic.ts +29 -28
- package/lib/utils/types.ts +51 -0
- package/package.json +4 -4
package/lib/index.ts
CHANGED
|
@@ -3,46 +3,32 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import { createReadStream } from "node:fs";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
|
|
6
|
+
import type net from "node:net";
|
|
7
|
+
import type { Readable } from "node:stream";
|
|
8
|
+
import type { Buffer } from "node:buffer";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
resolveCompressionOptions,
|
|
12
|
+
compressAndSend
|
|
13
|
+
} from "./internal/compression";
|
|
14
|
+
import { MIME_TYPES } from "./internal/mimeTypes";
|
|
15
|
+
import { Router } from "./internal/router";
|
|
16
|
+
import { frameworkError, ErrorCode } from "./internal/errors";
|
|
17
|
+
|
|
18
|
+
export { frameworkError, ErrorCode };
|
|
19
|
+
|
|
6
20
|
import type {
|
|
7
21
|
StringMap,
|
|
22
|
+
CpeakHttpServer,
|
|
23
|
+
CpeakOptions,
|
|
8
24
|
CpeakRequest,
|
|
9
25
|
CpeakResponse,
|
|
10
26
|
Middleware,
|
|
11
27
|
RouteMiddleware,
|
|
12
|
-
Handler
|
|
13
|
-
RoutesMap
|
|
28
|
+
Handler
|
|
14
29
|
} from "./types";
|
|
15
30
|
|
|
16
|
-
|
|
17
|
-
export function frameworkError(
|
|
18
|
-
message: string,
|
|
19
|
-
skipFn: Function,
|
|
20
|
-
code?: string,
|
|
21
|
-
status?: number
|
|
22
|
-
) {
|
|
23
|
-
const err = new Error(message) as Error & {
|
|
24
|
-
code?: string;
|
|
25
|
-
cpeak_err?: boolean;
|
|
26
|
-
};
|
|
27
|
-
Error.captureStackTrace(err, skipFn);
|
|
28
|
-
|
|
29
|
-
err.cpeak_err = true;
|
|
30
|
-
|
|
31
|
-
if (code) err.code = code;
|
|
32
|
-
if (status) (err as any).status = status;
|
|
33
|
-
|
|
34
|
-
return err;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export enum ErrorCode {
|
|
38
|
-
MISSING_MIME = "CPEAK_ERR_MISSING_MIME",
|
|
39
|
-
FILE_NOT_FOUND = "CPEAK_ERR_FILE_NOT_FOUND",
|
|
40
|
-
NOT_A_FILE = "CPEAK_ERR_NOT_A_FILE",
|
|
41
|
-
SEND_FILE_FAIL = "CPEAK_ERR_SEND_FILE_FAIL",
|
|
42
|
-
INVALID_JSON = "CPEAK_ERR_INVALID_JSON",
|
|
43
|
-
PAYLOAD_TOO_LARGE = "CPEAK_ERR_PAYLOAD_TOO_LARGE",
|
|
44
|
-
WEAK_SECRET = "CPEAK_ERR_WEAK_SECRET"
|
|
45
|
-
}
|
|
31
|
+
import type { ResolvedCompressionConfig } from "./internal/types";
|
|
46
32
|
|
|
47
33
|
export class CpeakIncomingMessage extends http.IncomingMessage {
|
|
48
34
|
// We define body and params here for better V8 optimization (not changing the shape of the object at runtime)
|
|
@@ -73,14 +59,22 @@ export class CpeakIncomingMessage extends http.IncomingMessage {
|
|
|
73
59
|
}
|
|
74
60
|
|
|
75
61
|
export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessage> {
|
|
62
|
+
// Set per-request from the Cpeak instance. Undefined when compression isn't enabled.
|
|
63
|
+
_compression?: ResolvedCompressionConfig;
|
|
64
|
+
|
|
76
65
|
// Send a file back to the client
|
|
77
|
-
async sendFile(path: string, mime
|
|
66
|
+
async sendFile(path: string, mime?: string) {
|
|
78
67
|
if (!mime) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
68
|
+
const dotIndex = path.lastIndexOf(".");
|
|
69
|
+
const fileExtension = dotIndex >= 0 ? path.slice(dotIndex + 1) : "";
|
|
70
|
+
mime = MIME_TYPES[fileExtension];
|
|
71
|
+
if (!mime) {
|
|
72
|
+
throw frameworkError(
|
|
73
|
+
`MIME type is missing for "${path}". Pass it as the second argument or register the extension via cpeak({ mimeTypes: { ${fileExtension || "ext"}: "..." } }).`,
|
|
74
|
+
this.sendFile,
|
|
75
|
+
ErrorCode.MISSING_MIME
|
|
76
|
+
);
|
|
77
|
+
}
|
|
84
78
|
}
|
|
85
79
|
|
|
86
80
|
try {
|
|
@@ -93,6 +87,17 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
|
|
|
93
87
|
);
|
|
94
88
|
}
|
|
95
89
|
|
|
90
|
+
if (this._compression) {
|
|
91
|
+
await compressAndSend(
|
|
92
|
+
this,
|
|
93
|
+
mime,
|
|
94
|
+
createReadStream(path),
|
|
95
|
+
this._compression,
|
|
96
|
+
stat.size
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
96
101
|
this.setHeader("Content-Type", mime);
|
|
97
102
|
this.setHeader("Content-Length", String(stat.size));
|
|
98
103
|
|
|
@@ -136,43 +141,96 @@ export class CpeakServerResponse extends http.ServerResponse<CpeakIncomingMessag
|
|
|
136
141
|
this.end();
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
// Send a json data back to the client
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
// Send a json data back to the client.
|
|
145
|
+
// This is only good for bodies that their size is less than the highWaterMark value.
|
|
146
|
+
json(data: any): Promise<void> {
|
|
147
|
+
const body = JSON.stringify(data);
|
|
148
|
+
if (this._compression) {
|
|
149
|
+
return compressAndSend(this, "application/json", body, this._compression);
|
|
150
|
+
}
|
|
142
151
|
this.setHeader("Content-Type", "application/json");
|
|
143
|
-
this.end(
|
|
152
|
+
this.end(body);
|
|
153
|
+
return Promise.resolve();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Explicit compression entry point. A developer can use this in any custom handler to compress arbitrary responses
|
|
157
|
+
compress(
|
|
158
|
+
mime: string,
|
|
159
|
+
body: Buffer | string | Readable,
|
|
160
|
+
size?: number
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
if (!this._compression) {
|
|
163
|
+
throw frameworkError(
|
|
164
|
+
"compression is not enabled. Pass `compression` to cpeak({ compression: true | { ... } }) to use res.compress.",
|
|
165
|
+
this.compress,
|
|
166
|
+
ErrorCode.COMPRESSION_NOT_ENABLED
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return compressAndSend(this, mime, body, this._compression, size);
|
|
144
170
|
}
|
|
145
171
|
}
|
|
146
172
|
|
|
147
173
|
export class Cpeak {
|
|
148
|
-
#server:
|
|
149
|
-
#
|
|
174
|
+
#server: CpeakHttpServer;
|
|
175
|
+
#router: Router;
|
|
150
176
|
#middleware: Middleware[];
|
|
151
177
|
#handleErr?: (err: unknown, req: CpeakRequest, res: CpeakResponse) => void;
|
|
178
|
+
#fallback?: Handler;
|
|
179
|
+
#compression?: ResolvedCompressionConfig;
|
|
152
180
|
|
|
153
|
-
constructor() {
|
|
181
|
+
constructor(options: CpeakOptions = {}) {
|
|
154
182
|
this.#server = http.createServer({
|
|
155
183
|
IncomingMessage: CpeakIncomingMessage,
|
|
156
184
|
ServerResponse: CpeakServerResponse
|
|
157
185
|
});
|
|
158
|
-
this.#
|
|
186
|
+
this.#router = new Router();
|
|
159
187
|
this.#middleware = [];
|
|
160
188
|
|
|
189
|
+
// Resolve compression options once at app startup.
|
|
190
|
+
if (options.compression) {
|
|
191
|
+
this.#compression = resolveCompressionOptions(options.compression);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Merge developer-supplied mime types with the defaults once at startup
|
|
195
|
+
if (options.mimeTypes) Object.assign(MIME_TYPES, options.mimeTypes);
|
|
196
|
+
|
|
161
197
|
this.#server.on(
|
|
162
198
|
"request",
|
|
163
199
|
async (req: CpeakRequest, res: CpeakResponse) => {
|
|
200
|
+
res._compression = this.#compression;
|
|
201
|
+
|
|
164
202
|
// Get the url without the URL parameters (query strings)
|
|
165
203
|
const qIndex = req.url?.indexOf("?");
|
|
166
204
|
const urlWithoutQueries =
|
|
167
205
|
qIndex === -1 ? req.url || "" : req.url?.substring(0, qIndex);
|
|
168
206
|
|
|
169
|
-
|
|
207
|
+
// Routes every error path through the registered handleErr. Awaits
|
|
208
|
+
// handleErr so its own async work (or a rejecting res.json under
|
|
209
|
+
// compression) is caught. If handleErr itself fails, we log and send a
|
|
210
|
+
// bare 500 so the client never gets a hung socket. Returns a Promise
|
|
211
|
+
// that never rejects to avoid unhandled promise rejections in case of errors in handleErr.
|
|
212
|
+
const dispatchError = async (error: unknown) => {
|
|
170
213
|
if (res.headersSent) {
|
|
171
214
|
req.socket?.destroy();
|
|
172
215
|
return;
|
|
173
216
|
}
|
|
174
217
|
res.setHeader("Connection", "close");
|
|
175
|
-
|
|
218
|
+
try {
|
|
219
|
+
await this.#handleErr?.(error, req, res);
|
|
220
|
+
} catch (handlerFailure) {
|
|
221
|
+
console.error(
|
|
222
|
+
"[cpeak] handleErr failed while processing:",
|
|
223
|
+
error,
|
|
224
|
+
"\nReason:",
|
|
225
|
+
handlerFailure
|
|
226
|
+
);
|
|
227
|
+
if (!res.headersSent) {
|
|
228
|
+
try {
|
|
229
|
+
res.statusCode = 500;
|
|
230
|
+
res.end();
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
176
234
|
};
|
|
177
235
|
|
|
178
236
|
// Run all the specific middleware functions for that router only and then run the handler
|
|
@@ -186,29 +244,22 @@ export class Cpeak {
|
|
|
186
244
|
// Our exit point...
|
|
187
245
|
if (index === middleware.length) {
|
|
188
246
|
// Call the route handler with the modified req and res objects.
|
|
189
|
-
// Also handle the promise errors by passing them to
|
|
247
|
+
// Also handle the promise errors by passing them to handleErr to save developers from having to manually wrap every handler in try/catch.
|
|
190
248
|
try {
|
|
191
|
-
await cb(req, res
|
|
249
|
+
await cb(req, res);
|
|
192
250
|
} catch (error) {
|
|
193
251
|
dispatchError(error);
|
|
194
252
|
}
|
|
195
253
|
} else {
|
|
196
|
-
// Handle the promise errors by passing them to
|
|
254
|
+
// Handle the promise errors by passing them to handleErr to save developers from having to manually wrap every route middleware in try/catch.
|
|
197
255
|
try {
|
|
198
|
-
await middleware[index](
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return dispatchError(error);
|
|
206
|
-
}
|
|
207
|
-
await runHandler(req, res, middleware, cb, index + 1);
|
|
208
|
-
},
|
|
209
|
-
// Error handler for a route middleware
|
|
210
|
-
dispatchError
|
|
211
|
-
);
|
|
256
|
+
await middleware[index](req, res, async (error?: unknown) => {
|
|
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
|
+
});
|
|
212
263
|
} catch (error) {
|
|
213
264
|
dispatchError(error);
|
|
214
265
|
}
|
|
@@ -224,32 +275,30 @@ export class Cpeak {
|
|
|
224
275
|
) => {
|
|
225
276
|
// Our exit point...
|
|
226
277
|
if (index === middleware.length) {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
0
|
|
248
|
-
);
|
|
249
|
-
}
|
|
278
|
+
const method = req.method?.toLowerCase() || "";
|
|
279
|
+
const found = this.#router.find(method, urlWithoutQueries || "");
|
|
280
|
+
|
|
281
|
+
if (found) {
|
|
282
|
+
req.params = found.params;
|
|
283
|
+
return await runHandler(
|
|
284
|
+
req,
|
|
285
|
+
res,
|
|
286
|
+
found.middleware,
|
|
287
|
+
found.handler,
|
|
288
|
+
0
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// If a fallback handler is registered, run it before falling back to the default 404
|
|
293
|
+
if (this.#fallback) {
|
|
294
|
+
try {
|
|
295
|
+
return await this.#fallback(req, res);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return dispatchError(error);
|
|
250
298
|
}
|
|
299
|
+
}
|
|
251
300
|
|
|
252
|
-
// If the requested route dose not exist, return 404
|
|
301
|
+
// If the requested route dose not exist, and developer has not registered the fallback handler, return 404
|
|
253
302
|
return res
|
|
254
303
|
.status(404)
|
|
255
304
|
.json({ error: `Cannot ${req.method} ${urlWithoutQueries}` });
|
|
@@ -273,8 +322,6 @@ export class Cpeak {
|
|
|
273
322
|
}
|
|
274
323
|
|
|
275
324
|
route(method: string, path: string, ...args: (RouteMiddleware | Handler)[]) {
|
|
276
|
-
if (!this.#routes[method]) this.#routes[method] = [];
|
|
277
|
-
|
|
278
325
|
// The last argument should always be our handler
|
|
279
326
|
const cb = args.pop() as Handler;
|
|
280
327
|
|
|
@@ -285,8 +332,7 @@ export class Cpeak {
|
|
|
285
332
|
// Rest will be our middleware functions
|
|
286
333
|
const middleware = args.flat() as RouteMiddleware[];
|
|
287
334
|
|
|
288
|
-
|
|
289
|
-
this.#routes[method].push({ path, regex, middleware, cb });
|
|
335
|
+
this.#router.add(method, path, middleware, cb);
|
|
290
336
|
}
|
|
291
337
|
|
|
292
338
|
beforeEach(cb: Middleware) {
|
|
@@ -297,8 +343,24 @@ export class Cpeak {
|
|
|
297
343
|
this.#handleErr = cb;
|
|
298
344
|
}
|
|
299
345
|
|
|
300
|
-
|
|
301
|
-
|
|
346
|
+
// This will handle any request that doesn't match any of the routes and middleware functions
|
|
347
|
+
fallback(cb: Handler) {
|
|
348
|
+
if (this.#fallback) {
|
|
349
|
+
throw frameworkError(
|
|
350
|
+
"Fallback handler is already registered. Only one fallback can be set per app.",
|
|
351
|
+
this.fallback,
|
|
352
|
+
ErrorCode.DUPLICATE_FALLBACK
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
this.#fallback = cb;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The first 3 listens are just TS overloads for better type inference and editor autocompletion. The last one is the actual implementation.
|
|
359
|
+
listen(port: number, cb?: () => void): CpeakHttpServer;
|
|
360
|
+
listen(port: number, host: string, cb?: () => void): CpeakHttpServer;
|
|
361
|
+
listen(options: net.ListenOptions, cb?: () => void): CpeakHttpServer;
|
|
362
|
+
listen(...args: any[]) {
|
|
363
|
+
return this.#server.listen(...args);
|
|
302
364
|
}
|
|
303
365
|
|
|
304
366
|
address() {
|
|
@@ -306,29 +368,12 @@ export class Cpeak {
|
|
|
306
368
|
}
|
|
307
369
|
|
|
308
370
|
close(cb?: (err?: Error) => void) {
|
|
309
|
-
this.#server.close(cb);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// ------------------------------
|
|
313
|
-
// PRIVATE METHODS:
|
|
314
|
-
// ------------------------------
|
|
315
|
-
#pathToRegex(path: string) {
|
|
316
|
-
const regexString =
|
|
317
|
-
"^" + path.replace(/:\w+/g, "([^/]+)").replace(/\*/g, ".*") + "$";
|
|
318
|
-
|
|
319
|
-
return new RegExp(regexString);
|
|
371
|
+
return this.#server.close(cb);
|
|
320
372
|
}
|
|
321
373
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
param.slice(1)
|
|
326
|
-
);
|
|
327
|
-
const params: StringMap = {};
|
|
328
|
-
paramNames.forEach((name, index) => {
|
|
329
|
-
params[name] = match[index + 1];
|
|
330
|
-
});
|
|
331
|
-
return params;
|
|
374
|
+
// A getter for developers who want to access the underlying http server instance for advanced use cases that aren't covered by Cpeak
|
|
375
|
+
get server() {
|
|
376
|
+
return this.#server;
|
|
332
377
|
}
|
|
333
378
|
}
|
|
334
379
|
|
|
@@ -341,21 +386,30 @@ export {
|
|
|
341
386
|
auth,
|
|
342
387
|
hashPassword,
|
|
343
388
|
verifyPassword,
|
|
344
|
-
cookieParser
|
|
389
|
+
cookieParser,
|
|
390
|
+
cors
|
|
345
391
|
} from "./utils";
|
|
346
|
-
export type { AuthOptions, PbkdfOptions, CookieOptions } from "./utils";
|
|
347
392
|
|
|
348
393
|
export type {
|
|
394
|
+
AuthOptions,
|
|
395
|
+
PbkdfOptions,
|
|
396
|
+
CookieOptions,
|
|
397
|
+
CorsOptions
|
|
398
|
+
} from "./utils/types";
|
|
399
|
+
|
|
400
|
+
export type { CompressionOptions } from "./internal/types";
|
|
401
|
+
|
|
402
|
+
export type {
|
|
403
|
+
CpeakHttpServer,
|
|
404
|
+
CpeakOptions,
|
|
349
405
|
CpeakRequest,
|
|
350
406
|
CpeakResponse,
|
|
351
407
|
Next,
|
|
352
|
-
HandleErr,
|
|
353
408
|
Middleware,
|
|
354
409
|
RouteMiddleware,
|
|
355
|
-
Handler
|
|
356
|
-
RoutesMap
|
|
410
|
+
Handler
|
|
357
411
|
} from "./types";
|
|
358
412
|
|
|
359
|
-
export default function cpeak(): Cpeak {
|
|
360
|
-
return new Cpeak();
|
|
413
|
+
export default function cpeak(options?: CpeakOptions): Cpeak {
|
|
414
|
+
return new Cpeak(options);
|
|
361
415
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import zlib from "node:zlib";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import type { Transform } from "node:stream";
|
|
6
|
+
import type { ServerResponse } from "node:http";
|
|
7
|
+
import type { CompressionOptions, ResolvedCompressionConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
type Encoding = "br" | "gzip" | "deflate";
|
|
10
|
+
|
|
11
|
+
const COMPRESSIBLE_TYPE = /text|json|javascript|css|xml|svg/i;
|
|
12
|
+
const NO_TRANSFORM = /(?:^|,)\s*no-transform\s*(?:,|$)/i;
|
|
13
|
+
|
|
14
|
+
// Parse Accept-Encoding and pick a compression algorithm the server supports.
|
|
15
|
+
// Handles q=0 to disable an algorithm. Cpeak preference is fixed: br > gzip > deflate.
|
|
16
|
+
function pickEncoding(header: string): Encoding | null {
|
|
17
|
+
if (!header) return null;
|
|
18
|
+
|
|
19
|
+
const accepted: Record<string, number> = {};
|
|
20
|
+
let wildcard: number | undefined;
|
|
21
|
+
|
|
22
|
+
for (const part of header.split(",")) {
|
|
23
|
+
const [rawName, ...params] = part.trim().split(";");
|
|
24
|
+
const name = rawName.trim().toLowerCase();
|
|
25
|
+
if (!name) continue;
|
|
26
|
+
|
|
27
|
+
let q = 1;
|
|
28
|
+
for (const p of params) {
|
|
29
|
+
const m = p.trim().match(/^q=([\d.]+)$/i);
|
|
30
|
+
if (m) q = Number(m[1]);
|
|
31
|
+
}
|
|
32
|
+
if (Number.isNaN(q)) q = 0;
|
|
33
|
+
|
|
34
|
+
if (name === "*") wildcard = q;
|
|
35
|
+
else accepted[name] = q;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tryPick = (enc: Encoding): boolean => {
|
|
39
|
+
const q = enc in accepted ? accepted[enc] : wildcard;
|
|
40
|
+
return q !== undefined && q > 0;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (tryPick("br")) return "br";
|
|
44
|
+
if (tryPick("gzip")) return "gzip";
|
|
45
|
+
if (tryPick("deflate")) return "deflate";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handling the Vary HTTP header
|
|
50
|
+
function appendVary(res: ServerResponse, value: string) {
|
|
51
|
+
const existing = res.getHeader("Vary");
|
|
52
|
+
if (!existing) return res.setHeader("Vary", value);
|
|
53
|
+
const current = String(existing)
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (
|
|
58
|
+
current.includes("*") ||
|
|
59
|
+
current.some((v) => v.toLowerCase() === value.toLowerCase())
|
|
60
|
+
)
|
|
61
|
+
return;
|
|
62
|
+
res.setHeader("Vary", [...current, value].join(", "));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Brotli options. Zlib uses 11 (max), which is really slow for live
|
|
66
|
+
// responses. We go with 4 unless the developer specifies otherwise.
|
|
67
|
+
function brotliOptsFor(config: ResolvedCompressionConfig): zlib.BrotliOptions {
|
|
68
|
+
const userBrotli = config.brotli || {};
|
|
69
|
+
return {
|
|
70
|
+
...userBrotli,
|
|
71
|
+
params: {
|
|
72
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
73
|
+
...(userBrotli.params || {})
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createCompressorStream(
|
|
79
|
+
encoding: Encoding,
|
|
80
|
+
config: ResolvedCompressionConfig
|
|
81
|
+
): Transform {
|
|
82
|
+
if (encoding === "br")
|
|
83
|
+
return zlib.createBrotliCompress(brotliOptsFor(config));
|
|
84
|
+
if (encoding === "gzip") return zlib.createGzip(config.gzip);
|
|
85
|
+
return zlib.createDeflate(config.deflate);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Decides what to do with this response
|
|
89
|
+
function negotiate(
|
|
90
|
+
res: ServerResponse,
|
|
91
|
+
mime: string,
|
|
92
|
+
size: number,
|
|
93
|
+
config: ResolvedCompressionConfig
|
|
94
|
+
): { encoding: Encoding | null; eligible: boolean } {
|
|
95
|
+
// Whether this content type is worth trying to compress at all.
|
|
96
|
+
// Some types are already compressed and don't compress well.
|
|
97
|
+
if (!COMPRESSIBLE_TYPE.test(mime)) return { encoding: null, eligible: false };
|
|
98
|
+
|
|
99
|
+
if (res.req?.method === "HEAD") return { encoding: null, eligible: false };
|
|
100
|
+
|
|
101
|
+
// RFC specification: don't transform responses that ask not to be transformed.
|
|
102
|
+
const cc = res.getHeader("Cache-Control");
|
|
103
|
+
if (cc && NO_TRANSFORM.test(String(cc)))
|
|
104
|
+
return { encoding: null, eligible: false };
|
|
105
|
+
|
|
106
|
+
const existing = res.getHeader("Content-Encoding");
|
|
107
|
+
if (existing && existing !== "identity")
|
|
108
|
+
return { encoding: null, eligible: false };
|
|
109
|
+
|
|
110
|
+
if (size < config.threshold) return { encoding: null, eligible: true };
|
|
111
|
+
|
|
112
|
+
const encoding = pickEncoding(
|
|
113
|
+
String(res.req?.headers["accept-encoding"] || "")
|
|
114
|
+
);
|
|
115
|
+
return { encoding, eligible: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Converts into a Readable stream
|
|
119
|
+
function bodyAsReadable(body: Buffer | string | Readable): Readable {
|
|
120
|
+
if (Buffer.isBuffer(body)) return Readable.from([body]);
|
|
121
|
+
if (typeof body === "string") return Readable.from([Buffer.from(body)]);
|
|
122
|
+
return body;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolves compression options (or 'true' for defaults) into a
|
|
126
|
+
// complete config. Called once at Cpeak construction.
|
|
127
|
+
export function resolveCompressionOptions(
|
|
128
|
+
input: true | CompressionOptions
|
|
129
|
+
): ResolvedCompressionConfig {
|
|
130
|
+
const options: CompressionOptions = input === true ? {} : input;
|
|
131
|
+
return {
|
|
132
|
+
threshold: options.threshold ?? 1024,
|
|
133
|
+
brotli: options.brotli ?? {},
|
|
134
|
+
gzip: options.gzip ?? {},
|
|
135
|
+
deflate: options.deflate ?? {}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The final point used by res.compress, res.json, res.sendFile and res.render
|
|
140
|
+
// when compression is enabled by the developer.
|
|
141
|
+
//
|
|
142
|
+
// Compression always goes through createGzip/createBrotliCompress/createDeflate
|
|
143
|
+
// streams which are async and run on libuv's thread pool.
|
|
144
|
+
export async function compressAndSend(
|
|
145
|
+
res: ServerResponse,
|
|
146
|
+
mime: string,
|
|
147
|
+
body: Buffer | string | Readable,
|
|
148
|
+
config: ResolvedCompressionConfig,
|
|
149
|
+
size?: number
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
res.setHeader("Content-Type", mime);
|
|
152
|
+
|
|
153
|
+
const knownSize: number = Buffer.isBuffer(body)
|
|
154
|
+
? body.length
|
|
155
|
+
: typeof body === "string"
|
|
156
|
+
? Buffer.byteLength(body)
|
|
157
|
+
: (size ?? Infinity);
|
|
158
|
+
|
|
159
|
+
const { encoding, eligible } = negotiate(res, mime, knownSize, config);
|
|
160
|
+
|
|
161
|
+
if (!encoding) {
|
|
162
|
+
if (eligible) appendVary(res, "Accept-Encoding");
|
|
163
|
+
if (Buffer.isBuffer(body) || typeof body === "string") {
|
|
164
|
+
res.setHeader("Content-Length", String(knownSize));
|
|
165
|
+
res.end(body);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (size !== undefined) res.setHeader("Content-Length", String(size));
|
|
169
|
+
await pipeline(body, res);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
res.setHeader("Content-Encoding", encoding);
|
|
174
|
+
appendVary(res, "Accept-Encoding");
|
|
175
|
+
await pipeline(
|
|
176
|
+
bodyAsReadable(body),
|
|
177
|
+
createCompressorStream(encoding, config),
|
|
178
|
+
res
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
) {
|
|
8
|
+
const err = new Error(message) as Error & {
|
|
9
|
+
code?: string;
|
|
10
|
+
cpeak_err?: boolean;
|
|
11
|
+
};
|
|
12
|
+
Error.captureStackTrace(err, skipFn);
|
|
13
|
+
|
|
14
|
+
err.cpeak_err = true;
|
|
15
|
+
|
|
16
|
+
if (code) err.code = code;
|
|
17
|
+
if (status) (err as any).status = status;
|
|
18
|
+
|
|
19
|
+
return err;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export enum ErrorCode {
|
|
23
|
+
MISSING_MIME = "CPEAK_ERR_MISSING_MIME",
|
|
24
|
+
FILE_NOT_FOUND = "CPEAK_ERR_FILE_NOT_FOUND",
|
|
25
|
+
NOT_A_FILE = "CPEAK_ERR_NOT_A_FILE",
|
|
26
|
+
SEND_FILE_FAIL = "CPEAK_ERR_SEND_FILE_FAIL",
|
|
27
|
+
INVALID_JSON = "CPEAK_ERR_INVALID_JSON",
|
|
28
|
+
PAYLOAD_TOO_LARGE = "CPEAK_ERR_PAYLOAD_TOO_LARGE",
|
|
29
|
+
WEAK_SECRET = "CPEAK_ERR_WEAK_SECRET",
|
|
30
|
+
COMPRESSION_NOT_ENABLED = "CPEAK_ERR_COMPRESSION_NOT_ENABLED",
|
|
31
|
+
// For router:
|
|
32
|
+
DUPLICATE_ROUTE = "CPEAK_ERR_DUPLICATE_ROUTE",
|
|
33
|
+
INVALID_ROUTE = "CPEAK_ERR_INVALID_ROUTE",
|
|
34
|
+
DUPLICATE_FALLBACK = "CPEAK_ERR_DUPLICATE_FALLBACK"
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
webmanifest: "application/manifest+json"
|
|
22
|
+
};
|