@coderbuzz/ken 0.1.4 → 0.2.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 CHANGED
@@ -25,8 +25,8 @@ built-in WebSocket support.
25
25
  the box.
26
26
  - **Schema Validation**: Validate request params, query, headers, cookies, and
27
27
  body with inline schemas.
28
- - **Built-in Middleware**: JWT, CORS, sessions, compression, rate limiting,
29
- secure headers, and more.
28
+ - **Built-in Middleware**: JWT, JWK/JWKS, CORS, sessions, compression, rate
29
+ limiting, secure headers, and more.
30
30
  - **WebSocket Support**: Real-time connections with pub/sub, ping/pong, and
31
31
  typed upgrade data.
32
32
  - **Performance-Driven**: Minimal overhead engineered for high throughput and
@@ -49,12 +49,11 @@ npm install @coderbuzz/ken
49
49
  import { AppServer } from "npm:@coderbuzz/ken";
50
50
  ```
51
51
 
52
- For Node.js, also install a peer for ESM compatibility:
53
-
54
- ```sh
55
- npm install tsx
56
- node --import tsx/esm server.ts
57
- ```
52
+ > **Node.js**: The package ships as ESM. Your project must have
53
+ > `"type": "module"` in `package.json`, or use the `.mjs` extension. Node.js 18+
54
+ > required. If you write your app in TypeScript, use a runtime that supports it
55
+ > natively (Bun, Deno) or a transpiler such as [tsx](https://tsx.is) / `tsc` for
56
+ > Node.js.
58
57
 
59
58
  ---
60
59
 
@@ -73,14 +72,38 @@ console.log(`Listening on ${hostname}:${port}`);
73
72
 
74
73
  ---
75
74
 
75
+ ## App vs AppServer
76
+
77
+ | Class | Purpose |
78
+ | ----------- | ----------------------------------------------------------------------------- |
79
+ | `App` | Pure router — no server lifecycle. Used for sub-apps and modular composition. |
80
+ | `AppServer` | `App` + `run()` / `stop()`. The entry point for a server process. |
81
+
82
+ ```ts
83
+ import { App, AppServer } from "@coderbuzz/ken";
84
+
85
+ // Root server
86
+ const app = new AppServer({ port: 3000, hostname: "0.0.0.0" });
87
+
88
+ // Sub-app (pure router)
89
+ const api = new App();
90
+ api.get("/users", handler);
91
+ app.use("/api/v1", api);
92
+
93
+ const { hostname, port } = await app.run();
94
+ await app.stop(); // graceful shutdown
95
+ ```
96
+
97
+ ---
98
+
76
99
  ## Routing
77
100
 
78
101
  ### Static Routes
79
102
 
80
103
  ```ts
81
- app.get("/", "Hello Ken!");
104
+ app.get("/", "Hello Ken!"); // string → text/plain
82
105
  app.get("/health", "OK");
83
- app.get("/version", { version: "1.0.0" }); // plain objects are JSON-serialized
106
+ app.get("/version", { version: "1.0.0" }); // object JSON-serialized
84
107
  ```
85
108
 
86
109
  ### Dynamic Params
@@ -109,17 +132,37 @@ app.get("/files/*", (ctx) => new Response(`File: ${ctx.params["*"]}`));
109
132
  ### HTTP Methods
110
133
 
111
134
  ```ts
135
+ app.get("/items", handler);
112
136
  app.post("/items", handler);
113
137
  app.put("/items/:id", handler);
114
138
  app.patch("/items/:id", handler);
115
139
  app.delete("/items/:id", handler);
140
+ app.head("/items", handler);
141
+ app.options("/items", handler);
142
+ ```
143
+
144
+ ### Route Introspection
145
+
146
+ ```ts
147
+ // Returns RouteInfo[] — { method, path }[]
148
+ const routes = app.getRoutes();
149
+
150
+ // Print a colored table to console
151
+ app.printRoutes();
152
+ // ┌──────────┬────────────────────┐
153
+ // │ Method │ Path │
154
+ // ├──────────┼────────────────────┤
155
+ // │ GET │ / │
156
+ // │ POST │ /users │
157
+ // │ WS │ /chat │
158
+ // └──────────┴────────────────────┘
116
159
  ```
117
160
 
118
161
  ---
119
162
 
120
163
  ## Schema Validation
121
164
 
122
- Ken validates request data inline via the schema object (first argument before
165
+ Ken validates request data inline via the schema object (second argument before
123
166
  the handler). Use
124
167
  [`@coderbuzz/kyo`](https://www.npmjs.com/package/@coderbuzz/kyo) for schema
125
168
  builders.
@@ -128,6 +171,7 @@ builders.
128
171
  import {
129
172
  boolean,
130
173
  coerce,
174
+ date,
131
175
  number,
132
176
  object,
133
177
  optional,
@@ -218,6 +262,21 @@ app.post("/api/submit", {
218
262
  });
219
263
  ```
220
264
 
265
+ ### Date Validation
266
+
267
+ ```ts
268
+ app.post("/api/register", {
269
+ json: object({
270
+ born: coerce(
271
+ date({ min: new Date("1900-01-01"), max: new Date("2025-12-31") }),
272
+ ),
273
+ }),
274
+ }, async (ctx) => {
275
+ const { born } = await ctx.json;
276
+ return Response.json({ born: born.toISOString() });
277
+ });
278
+ ```
279
+
221
280
  ---
222
281
 
223
282
  ## Middleware & State
@@ -268,11 +327,15 @@ app.post("/admin/action", {
268
327
  if ((ctx.state as any).auth.role !== "admin") {
269
328
  throw new Response("Forbidden", { status: 403 });
270
329
  }
330
+ // void return — not included in ctx.state type
271
331
  },
272
332
  },
273
333
  }, () => Response.json({ message: "Action performed" }));
274
334
  ```
275
335
 
336
+ > **Note:** Middleware with `void` return types are excluded from `ctx.state`.
337
+ > Only middleware that returns a value contributes to the state type.
338
+
276
339
  ### `onFinish` Callback
277
340
 
278
341
  ```ts
@@ -294,7 +357,8 @@ app.get("/with-logging", {
294
357
 
295
358
  ### `define()` — Scoped Middleware
296
359
 
297
- Apply middleware to a group of routes with full type inference:
360
+ Apply middleware to a group of routes with full type inference. Routes inside
361
+ the callback automatically inherit the state type:
298
362
 
299
363
  ```ts
300
364
  app.define(
@@ -316,16 +380,33 @@ app.define(
316
380
  );
317
381
  ```
318
382
 
383
+ `define()` can be nested for layered composition:
384
+
385
+ ```ts
386
+ app.define({ requestId: () => crypto.randomUUID() }, (app) => {
387
+ app.define({ timestamp: () => Date.now() }, (app) => {
388
+ app.get("/meta", (ctx) =>
389
+ Response.json({
390
+ id: ctx.state.requestId,
391
+ ts: ctx.state.timestamp,
392
+ }));
393
+ });
394
+ });
395
+ ```
396
+
319
397
  ### `apply()` — Global Middleware
320
398
 
321
399
  ```ts
322
- // Side-effect middleware (logging, metrics)
400
+ // Side-effect middleware (logging, metrics) — no state produced
323
401
  app.apply("/*", (ctx) => {
324
402
  console.log(ctx.method, ctx.url);
325
403
  });
326
404
 
327
- // State-producing middleware
405
+ // State-producing middleware — added to ctx.state for all matching routes
328
406
  app.apply("/*", { auth: (ctx) => verifyAuth(ctx) });
407
+
408
+ // Scoped to a prefix
409
+ app.apply("/api/*", { apiVersion: () => "v1" });
329
410
  ```
330
411
 
331
412
  ### `use()` — Mount Sub-Apps
@@ -335,7 +416,10 @@ const api = new App();
335
416
  api.get("/users", handler);
336
417
  api.get("/posts", handler);
337
418
 
338
- app.use("/api/v1", api);
419
+ app.use("/api/v1", api); // mounts at /api/v1/users and /api/v1/posts
420
+
421
+ // Without prefix — routes merged at root
422
+ app.use(api);
339
423
  ```
340
424
 
341
425
  ---
@@ -363,14 +447,24 @@ app.get("/login", (ctx) => {
363
447
  });
364
448
  return new Response("Logged in");
365
449
  });
450
+
451
+ // Multiple cookies
452
+ app.get("/setup", (ctx) => {
453
+ ctx.setCookie("theme", "dark");
454
+ ctx.setCookie("lang", "en", { path: "/", maxAge: 86400 });
455
+ return new Response("OK");
456
+ });
366
457
  ```
367
458
 
459
+ All cookie options: `path`, `domain`, `maxAge`, `expires`, `httpOnly`, `secure`,
460
+ `sameSite` (`'Strict' | 'Lax' | 'None'`).
461
+
368
462
  ---
369
463
 
370
464
  ## Error Handling
371
465
 
372
466
  ```ts
373
- // Custom app-level error handler
467
+ // App-level error handler — fallback for all routes
374
468
  app.onError((error, ctx) => {
375
469
  console.error(ctx.method, ctx.url, error);
376
470
  return Response.json(
@@ -386,37 +480,134 @@ app.notFound((ctx) => {
386
480
  return Response.json({ error: "Not Found", path: ctx.url }, { status: 404 });
387
481
  });
388
482
 
483
+ // Route-level onError — takes priority over app-level
484
+ app.get("/validate", {
485
+ onError: (error, ctx) => {
486
+ return Response.json({ custom: true, message: String(error) }, {
487
+ status: 422,
488
+ });
489
+ },
490
+ }, () => {
491
+ throw new Error("validation failed");
492
+ });
493
+
389
494
  // Throw a Response to short-circuit with a specific status
390
- app.get("/secret", (ctx) => {
495
+ app.get("/secret", () => {
391
496
  throw new Response("Forbidden", { status: 403 });
392
497
  });
393
498
  ```
394
499
 
500
+ ### Error Propagation Rules
501
+
502
+ 1. **Route-level `onError`** takes priority over app-level.
503
+ 2. **Thrown `Response`** instances short-circuit the chain and are returned
504
+ as-is (the `onError` handler receives the `Response` as the error value).
505
+ 3. Sub-app `onError` is inherited by routes mounted via `app.use()`.
506
+ 4. Middleware errors are caught by the route's `onError` (or app-level fallback)
507
+ — they behave the same as handler errors.
508
+
509
+ ### Sub-App Error Scoping
510
+
511
+ ```ts
512
+ const subApp = new App();
513
+ subApp.onError((error) => Response.json({ appError: true }, { status: 400 }));
514
+ subApp.get("/fail", () => {
515
+ throw new Error("fail");
516
+ });
517
+ // Route-level overrides sub-app error handler
518
+ subApp.get("/custom", {
519
+ onError: () => Response.json({ routeError: true }, { status: 418 }),
520
+ }, () => {
521
+ throw new Error("custom");
522
+ });
523
+ app.use("/sub", subApp);
524
+ ```
525
+
526
+ ### Custom Not Found Handlers
527
+
528
+ ```ts
529
+ // Sub-app scoped 404 (only matches /nf-api/* paths)
530
+ const apiApp = new App();
531
+ apiApp.get("/items", () => Response.json([{ id: 1 }]));
532
+ apiApp.notFound((ctx) =>
533
+ Response.json({ error: "API not found", path: ctx.url }, { status: 404 })
534
+ );
535
+ app.use("/nf-api", apiApp);
536
+
537
+ // define()-scoped notFound (inherits middleware state)
538
+ app.define({ reqUser: () => "user123" }, (app) => {
539
+ app.get("/dashboard", (ctx) => Response.json({ user: ctx.state.reqUser }));
540
+ app.notFound((ctx) =>
541
+ Response.json({ error: "Not Found", user: ctx.state.reqUser }, {
542
+ status: 404,
543
+ })
544
+ );
545
+ });
546
+
547
+ // Global fallback
548
+ app.notFound((ctx) =>
549
+ Response.json({ error: "Global not found", method: ctx.method }, {
550
+ status: 404,
551
+ })
552
+ );
553
+ ```
554
+
395
555
  ---
396
556
 
397
557
  ## Built-in Middleware
398
558
 
559
+ All middleware are used via the `state` key in the schema object (per-route) or
560
+ via `define()` / `apply()` (scoped). They return typed values into `ctx.state`.
561
+
399
562
  ### CORS
400
563
 
564
+ `cors()` returns an `App` instance. Mount it with `app.use()`:
565
+
401
566
  ```ts
402
567
  import { cors } from "@coderbuzz/ken";
403
568
 
404
- const api = cors({ origin: "https://example.com", credentials: true });
405
- api.get("/", () => Response.json({ data: true }));
406
- app.use("/api", api);
569
+ // CORS for specific routes define routes inside the cors app
570
+ const apiCors = cors({ origin: "https://example.com", credentials: true });
571
+ apiCors.get("/data", () => Response.json({ data: 1 }));
572
+ app.use("/api", apiCors);
573
+
574
+ // CORS with wildcard (all origins)
575
+ const publicCors = cors({ origin: "*" });
576
+ publicCors.get("/", () => Response.json({ public: true }));
577
+ app.use("/public", publicCors);
578
+
579
+ // CORS with dynamic origin resolver
580
+ const dynamicCors = cors({
581
+ origin: (requestOrigin, ctx) => {
582
+ const allowed = ["https://app.example.com", "https://admin.example.com"];
583
+ return allowed.includes(requestOrigin) ? requestOrigin : allowed[0];
584
+ },
585
+ allowMethods: ["GET", "POST"],
586
+ allowHeaders: ["Content-Type", "Authorization"],
587
+ exposeHeaders: ["X-Request-Id"],
588
+ maxAge: 86400,
589
+ credentials: true,
590
+ });
407
591
  ```
408
592
 
409
593
  ### JWT
410
594
 
411
595
  ```ts
412
- import { jwt, signJwt } from "@coderbuzz/ken";
596
+ import { decodeJwt, jwt, signJwt, verifyJwt } from "@coderbuzz/ken";
413
597
 
414
598
  // Sign a token
415
599
  app.get("/token", async () => {
416
- const token = await signJwt({
417
- sub: "user123",
418
- exp: Math.floor(Date.now() / 1000) + 3600,
419
- }, "secret");
600
+ const token = await signJwt(
601
+ {
602
+ sub: "user123",
603
+ iss: "my-app",
604
+ aud: "my-api",
605
+ exp: Math.floor(Date.now() / 1000) + 3600,
606
+ iat: Math.floor(Date.now() / 1000),
607
+ },
608
+ "secret",
609
+ "HS256", // default — also supports HS384, HS512
610
+ );
420
611
  return Response.json({ token });
421
612
  });
422
613
 
@@ -424,6 +615,46 @@ app.get("/token", async () => {
424
615
  app.get("/secure", {
425
616
  state: { auth: jwt({ secret: "secret" }) },
426
617
  }, (ctx) => Response.json({ payload: ctx.state.auth }));
618
+
619
+ // With claims validation
620
+ app.get("/strict", {
621
+ state: {
622
+ auth: jwt({
623
+ secret: "secret",
624
+ issuer: "my-app",
625
+ audience: "my-api",
626
+ clockTolerance: 5, // seconds
627
+ }),
628
+ },
629
+ }, (ctx) => Response.json({ sub: ctx.state.auth.sub }));
630
+ ```
631
+
632
+ ### JWK / JWKS (RSA, ECDSA)
633
+
634
+ ```ts
635
+ import { jwk } from "@coderbuzz/ken";
636
+
637
+ // Verify tokens against a JWKS endpoint (e.g., Auth0, Cognito)
638
+ app.get("/secure", {
639
+ state: {
640
+ auth: jwk({
641
+ jwksUrl: "https://your-auth-provider/.well-known/jwks.json",
642
+ issuer: "https://your-auth-provider/",
643
+ audience: "your-api-identifier",
644
+ cacheTtl: 600_000, // 10 minutes
645
+ }),
646
+ },
647
+ }, (ctx) => Response.json({ payload: ctx.state.auth }));
648
+
649
+ // With pre-loaded keys (no HTTP fetch)
650
+ app.get("/secure2", {
651
+ state: {
652
+ auth: jwk({
653
+ keys: [myRsaJwk],
654
+ issuer: "https://issuer.example.com",
655
+ }),
656
+ },
657
+ }, (ctx) => Response.json({ payload: ctx.state.auth }));
427
658
  ```
428
659
 
429
660
  ### Session
@@ -431,6 +662,7 @@ app.get("/secure", {
431
662
  ```ts
432
663
  import { session } from "@coderbuzz/ken";
433
664
 
665
+ // Sync — in-memory lookup
434
666
  const userSession = session({
435
667
  cookieName: "_sid",
436
668
  validate: (cookieValue) => {
@@ -438,6 +670,23 @@ const userSession = session({
438
670
  if (!user?.active) throw new Response("Unauthorized", { status: 401 });
439
671
  return user;
440
672
  },
673
+ // Optional: custom unauthorized response
674
+ onUnauthorized: (ctx) => new Response("Please log in", { status: 401 }),
675
+ });
676
+
677
+ // Async — database lookup or encrypted cookie
678
+ const encryptedSession = session({
679
+ cookieName: "_ak",
680
+ validate: async (cookieValue, ctx) => {
681
+ const apiKey = await decryptString(cookieValue, secretKey);
682
+ if (!MEDIA_OWNERS[apiKey]?.active) {
683
+ throw new Response(null, {
684
+ status: 302,
685
+ headers: { Location: "/auth?redirect=" + ctx.url },
686
+ });
687
+ }
688
+ return apiKey;
689
+ },
441
690
  });
442
691
 
443
692
  app.get("/dashboard", {
@@ -450,9 +699,23 @@ app.get("/dashboard", {
450
699
  ```ts
451
700
  import { basicAuth } from "@coderbuzz/ken";
452
701
 
702
+ // Static credentials
453
703
  app.get("/admin", {
454
704
  state: { auth: basicAuth({ username: "admin", password: "secret" }) },
455
705
  }, (ctx) => Response.json({ user: ctx.state.auth.username }));
706
+
707
+ // Custom verification function
708
+ app.get("/custom-admin", {
709
+ state: {
710
+ auth: basicAuth({
711
+ verifyUser: async (username, password, ctx) => {
712
+ return username === "admin" &&
713
+ password === await getHashedPassword(username);
714
+ },
715
+ realm: "Admin Area",
716
+ }),
717
+ },
718
+ }, (ctx) => Response.json({ user: ctx.state.auth.username }));
456
719
  ```
457
720
 
458
721
  ### Bearer Auth
@@ -460,17 +723,48 @@ app.get("/admin", {
460
723
  ```ts
461
724
  import { bearerAuth } from "@coderbuzz/ken";
462
725
 
726
+ // Single token
463
727
  app.get("/api/resource", {
464
728
  state: { auth: bearerAuth({ token: "my-token" }) },
465
729
  }, (ctx) => Response.json({ token: ctx.state.auth.token }));
730
+
731
+ // Multiple valid tokens
732
+ app.get("/api/multi", {
733
+ state: { auth: bearerAuth({ token: ["token-a", "token-b"] }) },
734
+ }, (ctx) => Response.json({ token: ctx.state.auth.token }));
735
+
736
+ // Custom verification
737
+ app.get("/api/dynamic", {
738
+ state: {
739
+ auth: bearerAuth({
740
+ verifyToken: async (token, ctx) => {
741
+ return await verifyInDatabase(token);
742
+ },
743
+ }),
744
+ },
745
+ }, (ctx) => Response.json({ token: ctx.state.auth.token }));
466
746
  ```
467
747
 
468
748
  ### Logger
469
749
 
750
+ `logger()` returns an `App` instance. Mount it with `app.use()`:
751
+
470
752
  ```ts
471
753
  import { logger } from "@coderbuzz/ken";
472
754
 
755
+ // Global logger (all routes)
473
756
  app.use(logger());
757
+
758
+ // Custom log function
759
+ app.use(logger({ logFn: (msg) => myLogService.info(msg) }));
760
+
761
+ // Custom format
762
+ app.use(logger({
763
+ format: ({ method, url, status, duration }) =>
764
+ `[${
765
+ new Date().toISOString()
766
+ }] ${method} ${url} → ${status} (${duration}ms)`,
767
+ }));
474
768
  ```
475
769
 
476
770
  ### Request ID
@@ -481,6 +775,18 @@ import { requestId } from "@coderbuzz/ken";
481
775
  app.get("/request", {
482
776
  state: { reqId: requestId() },
483
777
  }, (ctx) => Response.json({ id: ctx.state.reqId }));
778
+ // Response header: X-Request-Id: <uuid>
779
+
780
+ // Custom options
781
+ app.get("/request", {
782
+ state: {
783
+ reqId: requestId({
784
+ requestHeaderName: "X-Correlation-Id", // reuse incoming ID if present
785
+ headerName: "X-Request-Id",
786
+ generator: () => `req-${Date.now()}`,
787
+ }),
788
+ },
789
+ }, (ctx) => Response.json({ id: ctx.state.reqId }));
484
790
  ```
485
791
 
486
792
  ### Secure Headers
@@ -488,11 +794,29 @@ app.get("/request", {
488
794
  ```ts
489
795
  import { secureHeaders } from "@coderbuzz/ken";
490
796
 
797
+ // Use sensible defaults (Helmet-inspired)
798
+ app.define({ sec: secureHeaders() }, (app) => {
799
+ app.get("/", () => new Response("secure"));
800
+ });
801
+
802
+ // Customize individual headers
491
803
  app.get("/page", {
492
- state: { sec: secureHeaders() },
804
+ state: {
805
+ sec: secureHeaders({
806
+ xFrameOptions: "DENY",
807
+ contentSecurityPolicy: "default-src 'self'",
808
+ crossOriginOpenerPolicy: false, // disable a header
809
+ }),
810
+ },
493
811
  }, () => new Response("secure"));
494
812
  ```
495
813
 
814
+ Default headers set: `Cross-Origin-Opener-Policy`,
815
+ `Cross-Origin-Resource-Policy`, `Origin-Agent-Cluster`, `Referrer-Policy`,
816
+ `Strict-Transport-Security`, `X-Content-Type-Options`, `X-DNS-Prefetch-Control`,
817
+ `X-Download-Options`, `X-Frame-Options`, `X-Permitted-Cross-Domain-Policies`,
818
+ `X-XSS-Protection`. Also removes `X-Powered-By`.
819
+
496
820
  ### Compression
497
821
 
498
822
  ```ts
@@ -500,20 +824,44 @@ import { compress } from "@coderbuzz/ken";
500
824
 
501
825
  app.get("/data", {
502
826
  state: { encoding: compress() },
503
- }, (ctx) => Response.json({ encoding: ctx.state.encoding.encoding }));
827
+ }, (ctx) => {
828
+ // ctx.state.encoding.encoding is 'gzip' | 'deflate' | 'br' | null
829
+ return Response.json({ data: largePayload });
830
+ });
504
831
  ```
505
832
 
833
+ Note: Ken negotiates the encoding preference; actual body compression is handled
834
+ by the runtime (Bun, Deno) or a reverse proxy.
835
+
506
836
  ### Cache
507
837
 
508
838
  ```ts
509
839
  import { cache } from "@coderbuzz/ken";
510
840
 
841
+ // Cache for 1 hour (CDN-friendly)
511
842
  app.get("/static", {
512
843
  state: { caching: cache({ maxAge: 3600, public: true }) },
513
844
  }, () => new Response("cached content"));
845
+
846
+ // No caching
847
+ app.get("/dynamic", {
848
+ state: { caching: cache({ noStore: true }) },
849
+ }, () => Response.json({ time: Date.now() }));
850
+
851
+ // Advanced CDN directives
852
+ app.get("/cdn", {
853
+ state: {
854
+ caching: cache({
855
+ maxAge: 60,
856
+ sMaxAge: 3600,
857
+ staleWhileRevalidate: 300,
858
+ vary: "Accept-Language",
859
+ }),
860
+ },
861
+ }, () => new Response("cdn content"));
514
862
  ```
515
863
 
516
- ### Rate Limiting / Body Limit
864
+ ### Body Limit
517
865
 
518
866
  ```ts
519
867
  import { bodyLimit } from "@coderbuzz/ken";
@@ -533,7 +881,13 @@ import { timeout } from "@coderbuzz/ken";
533
881
 
534
882
  app.get("/slow", {
535
883
  state: { timeoutSig: timeout({ duration: 5000 }) },
536
- }, () => new Response("fast enough"));
884
+ }, async (ctx) => {
885
+ // Pass the AbortSignal to downstream fetch calls
886
+ const data = await fetch("https://api.example.com/data", {
887
+ signal: ctx.state.timeoutSig.signal,
888
+ });
889
+ return Response.json(await data.json());
890
+ });
537
891
  ```
538
892
 
539
893
  ### Timing
@@ -544,6 +898,15 @@ import { timing } from "@coderbuzz/ken";
544
898
  app.get("/timed", {
545
899
  state: { perf: timing() },
546
900
  }, () => new Response("timed"));
901
+ // Response header: Server-Timing: total;dur=2.50
902
+
903
+ // Custom metric name and description
904
+ app.get("/timed-custom", {
905
+ state: {
906
+ perf: timing({ name: "app", description: "Application processing" }),
907
+ },
908
+ }, () => new Response("timed"));
909
+ // Response header: Server-Timing: app;desc="Application processing";dur=2.50
547
910
  ```
548
911
 
549
912
  ### IP Restriction
@@ -551,9 +914,15 @@ app.get("/timed", {
551
914
  ```ts
552
915
  import { ipRestriction } from "@coderbuzz/ken";
553
916
 
917
+ // Allow list
554
918
  app.get("/internal", {
555
919
  state: { ipCheck: ipRestriction({ allowList: ["127.0.0.1", "::1"] }) },
556
920
  }, () => new Response("allowed"));
921
+
922
+ // Deny list
923
+ app.get("/public", {
924
+ state: { ipCheck: ipRestriction({ denyList: ["10.0.0.1"] }) },
925
+ }, () => new Response("public content"));
557
926
  ```
558
927
 
559
928
  ### CSRF Protection
@@ -561,11 +930,22 @@ app.get("/internal", {
561
930
  ```ts
562
931
  import { csrf } from "@coderbuzz/ken";
563
932
 
933
+ // Same-origin only (auto-detect)
934
+ app.post("/form", {
935
+ state: { protection: csrf() },
936
+ }, () => Response.json({ success: true }));
937
+
938
+ // Specific origins
564
939
  app.post("/form", {
565
940
  state: { protection: csrf({ origin: ["https://example.com"] }) },
566
941
  }, () => Response.json({ success: true }));
567
942
  ```
568
943
 
944
+ Note: CSRF only applies to unsafe methods (POST, PUT, PATCH, DELETE) with
945
+ form-compatible content types. JSON API requests
946
+ (`Content-Type:
947
+ application/json`) are automatically skipped.
948
+
569
949
  ### ETag
570
950
 
571
951
  ```ts
@@ -619,6 +999,15 @@ app.ws("/chat", {
619
999
  close(peer, code, reason) {
620
1000
  console.log("disconnected", code, reason);
621
1001
  },
1002
+ error(peer, error) {
1003
+ console.error("ws error", error);
1004
+ },
1005
+ ping(peer, data) {
1006
+ console.log("ping received");
1007
+ },
1008
+ pong(peer, data) {
1009
+ console.log("pong received — peer alive");
1010
+ },
622
1011
  });
623
1012
  ```
624
1013
 
@@ -630,7 +1019,7 @@ app.ws<{ userId: string }>("/auth", {
630
1019
  const url = new URL(req.url);
631
1020
  const userId = url.searchParams.get("userId");
632
1021
  if (!userId) return new Response("Unauthorized", { status: 401 });
633
- return { userId };
1022
+ return { userId }; // becomes peer.data
634
1023
  },
635
1024
  open(peer) {
636
1025
  peer.send(`Hello ${peer.data.userId}`);
@@ -650,10 +1039,11 @@ app.ws("/chat", {
650
1039
  peer.publish("chat", "someone joined");
651
1040
  },
652
1041
  message(peer, message) {
653
- peer.publish("chat", message);
654
- peer.send(`you: ${message}`);
1042
+ peer.publish("chat", message); // broadcast to all except sender
1043
+ peer.send(`you: ${message}`); // echo to sender
655
1044
  },
656
1045
  close(peer) {
1046
+ peer.unsubscribe("chat");
657
1047
  peer.publish("chat", "someone left");
658
1048
  },
659
1049
  });
@@ -661,6 +1051,9 @@ app.ws("/chat", {
661
1051
 
662
1052
  ### WsTopicHub (App-Level Pub/Sub)
663
1053
 
1054
+ Use `WsTopicHub` for pub/sub across all runtimes, including cross-topic
1055
+ broadcasts from HTTP routes or background tasks:
1056
+
664
1057
  ```ts
665
1058
  import { WsTopicHub } from "@coderbuzz/ken";
666
1059
 
@@ -674,7 +1067,7 @@ app.ws("/notifications", {
674
1067
  hub.unsubscribeAll(peer);
675
1068
  },
676
1069
  message(peer, _msg) {
677
- hub.markAlive(peer);
1070
+ hub.markAlive(peer); // heartbeat acknowledgment
678
1071
  },
679
1072
  });
680
1073
 
@@ -689,14 +1082,114 @@ app.post("/broadcast", async (ctx) => {
689
1082
  ### Ping/Pong (Heartbeat)
690
1083
 
691
1084
  ```ts
692
- app.ws("/live", {
693
- pong(peer) {
694
- console.log(`${peer.remoteAddress} is alive`);
695
- },
696
- message(peer, msg) {
697
- peer.send(msg);
1085
+ app.ws(
1086
+ "/live",
1087
+ {
1088
+ pong(peer) {
1089
+ console.log(`${peer.remoteAddress} is alive`);
1090
+ },
1091
+ message(peer, msg) {
1092
+ peer.send(msg);
1093
+ },
698
1094
  },
699
- }, { pingInterval: 30, pongTimeout: 10 });
1095
+ { pingInterval: 30, pongTimeout: 10 }, // WsOptions
1096
+ );
1097
+ ```
1098
+
1099
+ ### WsOptions
1100
+
1101
+ | Option | Type | Default | Description |
1102
+ | ------------------- | --------- | ------------ | ------------------------------------------ |
1103
+ | `maxPayloadLength` | `number` | `16_777_216` | Max message size in bytes (16 MB) |
1104
+ | `backpressureLimit` | `number` | `16_777_216` | Max send buffer size (16 MB) |
1105
+ | `pingInterval` | `number` | `30` | Seconds between server ping frames |
1106
+ | `pongTimeout` | `number` | `10` | Seconds to wait for pong before closing |
1107
+ | `perMessageDeflate` | `boolean` | `false` | Enable per-message compression |
1108
+ | `idleTimeout` | `number` | `120` | Seconds before idle connections are closed |
1109
+
1110
+ ---
1111
+
1112
+ ## WebSocket Client (WSClient)
1113
+
1114
+ Ken includes a built-in fault-tolerant WebSocket client with the Ken Binary
1115
+ WebSocket Protocol (KBWP) — 80–93% bandwidth reduction over JSON for protocol
1116
+ messages.
1117
+
1118
+ ```ts
1119
+ import { WSClient } from "@coderbuzz/ken";
1120
+
1121
+ const client = new WSClient("wss://api.example.com/ws", {
1122
+ token: "my-jwt", // auth token
1123
+ authMode: "message", // 'message' (binary, default) or 'query' (?token=)
1124
+ heartbeatInterval: 30_000, // ms between pings
1125
+ requestTimeout: 10_000, // sendWait() timeout
1126
+ maxRetries: Infinity, // reconnect attempts
1127
+ onConnect: () => console.log("Connected"),
1128
+ onDisconnect: (code, reason) => console.log("Disconnected", code),
1129
+ onReconnectAttempt: (attempt, delay) =>
1130
+ console.log(`Retry #${attempt} in ${delay}ms`),
1131
+ });
1132
+
1133
+ await client.connect();
1134
+
1135
+ // Fire-and-forget
1136
+ client.send("hello");
1137
+
1138
+ // Request-response (binary REQUEST frame + correlation ID)
1139
+ const result = await client.sendWait({ type: "getData", id: 42 });
1140
+
1141
+ // Pub/sub
1142
+ client.subscribe("events", (data) => console.log("Event:", data));
1143
+ client.publish("events", { action: "click" });
1144
+ client.unsubscribe("events");
1145
+
1146
+ await client.close();
1147
+ ```
1148
+
1149
+ ### wsClientProtocol — Server-Side Counterpart
1150
+
1151
+ ```ts
1152
+ import { wsClientProtocol } from "@coderbuzz/ken";
1153
+
1154
+ // Without auth
1155
+ app.use(
1156
+ "/chat",
1157
+ wsClientProtocol({
1158
+ pingInterval: 20,
1159
+ idleTimeout: 60,
1160
+ upgrade(req) {
1161
+ const name = new URL(req.url).searchParams.get("name") ?? "anon";
1162
+ return { username: name };
1163
+ },
1164
+ open(peer) {
1165
+ peer.subscribe("chat");
1166
+ },
1167
+ message(peer, msg) {
1168
+ peer.send(`echo: ${msg}`);
1169
+ },
1170
+ close(peer) {
1171
+ peer.unsubscribe("chat");
1172
+ },
1173
+ }),
1174
+ );
1175
+
1176
+ // With post-connect binary auth (secure)
1177
+ app.use(
1178
+ "/ws",
1179
+ wsClientProtocol<{ userId: string }>({
1180
+ authenticate: async (token) => {
1181
+ const user = await verifyJwt(token, "secret");
1182
+ return user ? { userId: String(user.sub) } : null;
1183
+ },
1184
+ tokenParam: "token", // also accept ?token= query param
1185
+ open(peer) {
1186
+ console.log("authed:", peer.data.userId);
1187
+ },
1188
+ message(peer, msg) {
1189
+ peer.send(msg);
1190
+ },
1191
+ }),
1192
+ );
700
1193
  ```
701
1194
 
702
1195
  ---
@@ -722,54 +1215,154 @@ app.get("/stream", () => {
722
1215
 
723
1216
  ```ts
724
1217
  import {
1218
+ getMimeType,
725
1219
  listDirectory,
726
1220
  receiveFiles,
727
1221
  saveFile,
728
1222
  sendFile,
729
1223
  } from "@coderbuzz/ken";
1224
+ ```
1225
+
1226
+ ### Serve a File
730
1227
 
731
- // Serve a file
1228
+ ```ts
1229
+ // Basic
732
1230
  app.get("/download", (ctx) => sendFile(ctx, "./assets/file.pdf"));
733
1231
 
734
- // List directory
1232
+ // With full options (enables ETag, Range, conditional requests)
1233
+ app.get(
1234
+ "/download/:name",
1235
+ (ctx) =>
1236
+ sendFile(ctx, `./uploads/${ctx.params.name}`, {
1237
+ download: true, // trigger browser download
1238
+ // download: "custom-name.pdf", // download with custom filename
1239
+ cacheControl: "public, max-age=3600",
1240
+ reqHeaders: ctx.headers, // enables ETag/Range/If-Modified-Since
1241
+ }),
1242
+ );
1243
+ ```
1244
+
1245
+ `sendFile` supports ETag / If-None-Match (→ 304), Last-Modified /
1246
+ If-Modified-Since (→ 304), and Range requests (→ 206 Partial Content). On Bun,
1247
+ uses `Bun.file()` for zero-copy sendfile.
1248
+
1249
+ ### List Directory
1250
+
1251
+ ```ts
735
1252
  app.get("/files", (ctx) => listDirectory(ctx, "./uploads"));
736
1253
 
737
- // Receive uploaded files
1254
+ // With options
1255
+ app.get("/files", async (ctx) => {
1256
+ return listDirectory(ctx, "./uploads", {
1257
+ recursive: true,
1258
+ maxDepth: 3,
1259
+ stats: true, // include size and modifiedAt (default: true)
1260
+ filter: (entry) => !entry.name.startsWith("."),
1261
+ });
1262
+ });
1263
+ ```
1264
+
1265
+ Each entry: `{ name, path, isDirectory, size, modifiedAt }`.
1266
+
1267
+ ### Receive Uploaded Files
1268
+
1269
+ ```ts
738
1270
  app.post("/upload", async (ctx) => {
739
- const files = await receiveFiles(ctx);
1271
+ const files = await receiveFiles(ctx, {
1272
+ maxFileSize: 5_000_000,
1273
+ maxFiles: 10,
1274
+ allowedTypes: ["image/png", "image/jpeg"],
1275
+ fields: ["avatar", "banner"],
1276
+ });
740
1277
  for (const file of files) {
1278
+ // file: { fieldName, fileName, type, size, data: ArrayBuffer }
741
1279
  await saveFile(file, "./uploads");
742
1280
  }
743
1281
  return Response.json({ count: files.length });
744
1282
  });
745
1283
  ```
746
1284
 
1285
+ ### MIME Type Detection
1286
+
1287
+ ```ts
1288
+ getMimeType("photo.jpg"); // 'image/jpeg'
1289
+ getMimeType(".css"); // 'text/css; charset=utf-8'
1290
+ getMimeType("data.bin"); // 'application/octet-stream'
1291
+ ```
1292
+
747
1293
  ---
748
1294
 
749
1295
  ## Utilities
750
1296
 
1297
+ ### Encryption (AES-GCM)
1298
+
751
1299
  ```ts
752
1300
  import {
753
- compressString,
754
- decompressString,
755
1301
  decryptString,
756
1302
  encryptString,
757
1303
  generateSecretKey,
758
- getPathname,
759
- memoize,
760
1304
  } from "@coderbuzz/ken";
761
1305
 
762
- // Encryption
763
- const key = await generateSecretKey();
764
- const encrypted = await encryptString("hello", key);
765
- const decrypted = await decryptString(encrypted, key);
1306
+ // Generate a random 256-bit key (sync)
1307
+ const key = generateSecretKey(); // base64-encoded string
1308
+
1309
+ // Encrypt / decrypt
1310
+ const encrypted = await encryptString("hello world", key);
1311
+ const original = await decryptString(encrypted, key);
1312
+ ```
1313
+
1314
+ Uses AES-256-GCM via Web Crypto API with LRU key caching. Output is
1315
+ base64-encoded `IV (12 bytes) + ciphertext`.
766
1316
 
767
- // Compression
768
- const compressed = await compressString("large text...");
1317
+ ### Compression
1318
+
1319
+ ```ts
1320
+ import { compressString, decompressString } from "@coderbuzz/ken";
1321
+
1322
+ // Default: gzip (standard Web API, compatible with all runtimes)
1323
+ const compressed = await compressString("large text payload...");
769
1324
  const original = await decompressString(compressed);
770
1325
 
771
- // Memoization
772
- const cached = memoize(expensiveFn, { ttl: 60_000 });
1326
+ // Custom encoding and quality level
1327
+ const gz = await compressString("text", { encoding: "gzip", level: 9 });
1328
+ const plain = await decompressString(gz, { encoding: "gzip" });
1329
+ ```
1330
+
1331
+ Supported encodings: `'gzip'` (default), `'deflate'`, `'deflate-raw'`.
1332
+ gzip/deflate levels: 0–9.
1333
+
1334
+ ### Memoization
1335
+
1336
+ ```ts
1337
+ import { memoize } from "@coderbuzz/ken";
1338
+
1339
+ // Sync — auto-detected
1340
+ const expensive = memoize((id: string) => computeResult(id));
1341
+ expensive("abc"); // computes
1342
+ expensive("abc"); // cached
1343
+
1344
+ // Async — auto-detected, with thundering herd protection
1345
+ const fetchUser = memoize(
1346
+ async (id: string) => db.users.findById(id),
1347
+ { ttl: 30_000, maxSize: 500 },
1348
+ );
1349
+
1350
+ // Multi-arg with custom key resolver
1351
+ const multi = memoize(
1352
+ (a: number, b: number) => a + b,
1353
+ { key: (a, b) => `${a}:${b}` },
1354
+ );
1355
+ ```
1356
+
1357
+ Options: `maxSize` (default: 256), `ttl` (ms, default: 0 = no expiry), `key`
1358
+ (custom key resolver).
1359
+
1360
+ ### URL Utilities
1361
+
1362
+ ```ts
1363
+ import { getPathname } from "@coderbuzz/ken";
1364
+
1365
+ getPathname("https://example.com/api/v1?q=1"); // '/api/v1'
773
1366
  ```
774
1367
 
775
1368
  ---
@@ -784,6 +1377,16 @@ if (isDeno) console.log("Running on Deno");
784
1377
  if (isNode) console.log("Running on Node.js");
785
1378
  ```
786
1379
 
1380
+ ### Node.js with uWebSockets.js
1381
+
1382
+ For maximum performance on Node.js, install `uWebSockets.js` and set the `UWS`
1383
+ environment variable:
1384
+
1385
+ ```sh
1386
+ npm install uWebSockets.js
1387
+ UWS=1 node --import tsx/esm server.ts
1388
+ ```
1389
+
787
1390
  ---
788
1391
 
789
1392
  ## Remote Info
@@ -795,6 +1398,30 @@ app.get("/ip", (ctx) => {
795
1398
  });
796
1399
  ```
797
1400
 
1401
+ Ken checks proxy headers in order: `CF-Connecting-IP`, `X-Real-IP`,
1402
+ `X-Forwarded-For`, `True-Client-IP` — then falls back to the raw socket address.
1403
+
1404
+ ---
1405
+
1406
+ ## Context API Reference
1407
+
1408
+ | Property | Type | Description |
1409
+ | ----------------------------------- | ----------------------------------- | ---------------------------------- |
1410
+ | `ctx.url` | `string` | Full request URL |
1411
+ | `ctx.method` | `string` | HTTP method (uppercase) |
1412
+ | `ctx.params` | `Record<string, string>` (or typed) | Route path parameters |
1413
+ | `ctx.query` | `Record<string, string>` (or typed) | URL query string parameters |
1414
+ | `ctx.headers` | `Record<string, string>` (or typed) | Request headers (lowercase keys) |
1415
+ | `ctx.cookies` | `Record<string, string>` (or typed) | Request cookies |
1416
+ | `ctx.json` | `Promise<any>` (or typed) | Parsed JSON body |
1417
+ | `ctx.text` | `Promise<string>` (or typed) | Raw text body |
1418
+ | `ctx.form` | `Promise<FormData>` (or typed) | Parsed form data body |
1419
+ | `ctx.body` | `any` | Raw body stream (runtime-specific) |
1420
+ | `ctx.state` | typed | State produced by middleware |
1421
+ | `ctx.remoteInfo` | `{ address: string; port: number }` | Client IP and port |
1422
+ | `ctx.setCookie(name, value, opts?)` | `void` | Set a response cookie |
1423
+ | `ctx.onFinish(cb)` | `void` | Register post-response callback |
1424
+
798
1425
  ---
799
1426
 
800
1427
  ## License