@logtape/koa 1.3.4 → 1.4.0-dev.409

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/deno.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@logtape/koa",
3
+ "version": "1.4.0-dev.409+63c2cd45",
4
+ "license": "MIT",
5
+ "exports": "./src/mod.ts",
6
+ "exclude": [
7
+ "coverage/",
8
+ "npm/",
9
+ "dist/"
10
+ ],
11
+ "tasks": {
12
+ "build": "pnpm build",
13
+ "test": "deno test --allow-env --allow-sys --allow-net",
14
+ "test:node": {
15
+ "dependencies": [
16
+ "build"
17
+ ],
18
+ "command": "node --experimental-transform-types --test"
19
+ },
20
+ "test:bun": {
21
+ "dependencies": [
22
+ "build"
23
+ ],
24
+ "command": "bun test"
25
+ },
26
+ "test-all": {
27
+ "dependencies": [
28
+ "test",
29
+ "test:node",
30
+ "test:bun"
31
+ ]
32
+ }
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logtape/koa",
3
- "version": "1.3.4",
3
+ "version": "1.4.0-dev.409+63c2cd45",
4
4
  "description": "Koa adapter for LogTape logging library",
5
5
  "keywords": [
6
6
  "logging",
@@ -47,12 +47,9 @@
47
47
  "./package.json": "./package.json"
48
48
  },
49
49
  "sideEffects": false,
50
- "files": [
51
- "dist/"
52
- ],
53
50
  "peerDependencies": {
54
51
  "koa": "^2.0.0 || ^3.0.0",
55
- "@logtape/logtape": "^1.3.4"
52
+ "@logtape/logtape": "^1.4.0-dev.409+63c2cd45"
56
53
  },
57
54
  "devDependencies": {
58
55
  "@alinea/suite": "^0.6.3",
@@ -0,0 +1,727 @@
1
+ import { suite } from "@alinea/suite";
2
+ import { assert, assertEquals, assertExists } from "@std/assert";
3
+ import { configure, type LogRecord, reset } from "@logtape/logtape";
4
+ import { type KoaContext, koaLogger, type KoaMiddleware } from "./mod.ts";
5
+
6
+ const test = suite(import.meta);
7
+
8
+ // Test fixture: Collect log records, filtering out internal LogTape meta logs
9
+ function createTestSink(): {
10
+ sink: (record: LogRecord) => void;
11
+ logs: LogRecord[];
12
+ } {
13
+ const logs: LogRecord[] = [];
14
+ return {
15
+ sink: (record: LogRecord) => {
16
+ if (record.category[0] !== "logtape") {
17
+ logs.push(record);
18
+ }
19
+ },
20
+ logs,
21
+ };
22
+ }
23
+
24
+ // Setup helper
25
+ async function setupLogtape(): Promise<{
26
+ logs: LogRecord[];
27
+ cleanup: () => Promise<void>;
28
+ }> {
29
+ const { sink, logs } = createTestSink();
30
+ await configure({
31
+ sinks: { test: sink },
32
+ loggers: [{ category: [], sinks: ["test"] }],
33
+ });
34
+ return { logs, cleanup: () => reset() };
35
+ }
36
+
37
+ // Mock Koa context
38
+ function createMockContext(overrides: Partial<KoaContext> = {}): KoaContext {
39
+ return {
40
+ method: "GET",
41
+ url: "/test",
42
+ path: "/test",
43
+ status: 200,
44
+ ip: "127.0.0.1",
45
+ response: {
46
+ length: undefined,
47
+ },
48
+ get: (field: string) => {
49
+ const headers: Record<string, string> = {
50
+ "user-agent": "test-agent/1.0",
51
+ "referer": "http://example.com",
52
+ "referrer": "http://example.com",
53
+ };
54
+ return headers[field.toLowerCase()] ?? "";
55
+ },
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ // Helper to run middleware
61
+ async function runMiddleware(
62
+ middleware: KoaMiddleware,
63
+ ctx: KoaContext,
64
+ next: () => Promise<void> = async () => {},
65
+ ): Promise<void> {
66
+ await middleware(ctx, next);
67
+ }
68
+
69
+ // ============================================
70
+ // Basic Middleware Creation Tests
71
+ // ============================================
72
+
73
+ test("koaLogger(): creates a middleware function", async () => {
74
+ const { cleanup } = await setupLogtape();
75
+ try {
76
+ const middleware = koaLogger();
77
+ assertEquals(typeof middleware, "function");
78
+ } finally {
79
+ await cleanup();
80
+ }
81
+ });
82
+
83
+ test("koaLogger(): logs request after response", async () => {
84
+ const { logs, cleanup } = await setupLogtape();
85
+ try {
86
+ const middleware = koaLogger();
87
+ const ctx = createMockContext();
88
+
89
+ await runMiddleware(middleware, ctx);
90
+
91
+ assertEquals(logs.length, 1);
92
+ } finally {
93
+ await cleanup();
94
+ }
95
+ });
96
+
97
+ // ============================================
98
+ // Category Configuration Tests
99
+ // ============================================
100
+
101
+ test("koaLogger(): uses default category ['koa']", async () => {
102
+ const { logs, cleanup } = await setupLogtape();
103
+ try {
104
+ const middleware = koaLogger();
105
+ const ctx = createMockContext();
106
+
107
+ await runMiddleware(middleware, ctx);
108
+
109
+ assertEquals(logs.length, 1);
110
+ assertEquals(logs[0].category, ["koa"]);
111
+ } finally {
112
+ await cleanup();
113
+ }
114
+ });
115
+
116
+ test("koaLogger(): uses custom category array", async () => {
117
+ const { logs, cleanup } = await setupLogtape();
118
+ try {
119
+ const middleware = koaLogger({ category: ["myapp", "http"] });
120
+ const ctx = createMockContext();
121
+
122
+ await runMiddleware(middleware, ctx);
123
+
124
+ assertEquals(logs.length, 1);
125
+ assertEquals(logs[0].category, ["myapp", "http"]);
126
+ } finally {
127
+ await cleanup();
128
+ }
129
+ });
130
+
131
+ test("koaLogger(): accepts string category", async () => {
132
+ const { logs, cleanup } = await setupLogtape();
133
+ try {
134
+ const middleware = koaLogger({ category: "myapp" });
135
+ const ctx = createMockContext();
136
+
137
+ await runMiddleware(middleware, ctx);
138
+
139
+ assertEquals(logs.length, 1);
140
+ assertEquals(logs[0].category, ["myapp"]);
141
+ } finally {
142
+ await cleanup();
143
+ }
144
+ });
145
+
146
+ // ============================================
147
+ // Log Level Tests
148
+ // ============================================
149
+
150
+ test("koaLogger(): uses default log level 'info'", async () => {
151
+ const { logs, cleanup } = await setupLogtape();
152
+ try {
153
+ const middleware = koaLogger();
154
+ const ctx = createMockContext();
155
+
156
+ await runMiddleware(middleware, ctx);
157
+
158
+ assertEquals(logs.length, 1);
159
+ assertEquals(logs[0].level, "info");
160
+ } finally {
161
+ await cleanup();
162
+ }
163
+ });
164
+
165
+ test("koaLogger(): uses custom log level 'debug'", async () => {
166
+ const { logs, cleanup } = await setupLogtape();
167
+ try {
168
+ const middleware = koaLogger({ level: "debug" });
169
+ const ctx = createMockContext();
170
+
171
+ await runMiddleware(middleware, ctx);
172
+
173
+ assertEquals(logs.length, 1);
174
+ assertEquals(logs[0].level, "debug");
175
+ } finally {
176
+ await cleanup();
177
+ }
178
+ });
179
+
180
+ test("koaLogger(): uses custom log level 'warning'", async () => {
181
+ const { logs, cleanup } = await setupLogtape();
182
+ try {
183
+ const middleware = koaLogger({ level: "warning" });
184
+ const ctx = createMockContext();
185
+
186
+ await runMiddleware(middleware, ctx);
187
+
188
+ assertEquals(logs.length, 1);
189
+ assertEquals(logs[0].level, "warning");
190
+ } finally {
191
+ await cleanup();
192
+ }
193
+ });
194
+
195
+ // ============================================
196
+ // Format Tests - Combined (default)
197
+ // ============================================
198
+
199
+ test("koaLogger(): combined format logs structured properties", async () => {
200
+ const { logs, cleanup } = await setupLogtape();
201
+ try {
202
+ const middleware = koaLogger({ format: "combined" });
203
+ const ctx = createMockContext({
204
+ response: { length: 123 },
205
+ });
206
+
207
+ await runMiddleware(middleware, ctx);
208
+
209
+ assertEquals(logs.length, 1);
210
+ const props = logs[0].properties;
211
+ assertEquals(props.method, "GET");
212
+ assertEquals(props.url, "/test");
213
+ assertEquals(props.path, "/test");
214
+ assertEquals(props.status, 200);
215
+ assertExists(props.responseTime);
216
+ assertEquals(props.contentLength, 123);
217
+ assertEquals(props.remoteAddr, "127.0.0.1");
218
+ assertEquals(props.userAgent, "test-agent/1.0");
219
+ assertEquals(props.referrer, "http://example.com");
220
+ } finally {
221
+ await cleanup();
222
+ }
223
+ });
224
+
225
+ // ============================================
226
+ // Format Tests - Common
227
+ // ============================================
228
+
229
+ test("koaLogger(): common format excludes referrer and userAgent", async () => {
230
+ const { logs, cleanup } = await setupLogtape();
231
+ try {
232
+ const middleware = koaLogger({ format: "common" });
233
+ const ctx = createMockContext();
234
+
235
+ await runMiddleware(middleware, ctx);
236
+
237
+ assertEquals(logs.length, 1);
238
+ const props = logs[0].properties;
239
+ assertEquals(props.method, "GET");
240
+ assertEquals(props.path, "/test");
241
+ assertEquals(props.status, 200);
242
+ assertEquals(props.referrer, undefined);
243
+ assertEquals(props.userAgent, undefined);
244
+ } finally {
245
+ await cleanup();
246
+ }
247
+ });
248
+
249
+ // ============================================
250
+ // Format Tests - Dev
251
+ // ============================================
252
+
253
+ test("koaLogger(): dev format returns string message", async () => {
254
+ const { logs, cleanup } = await setupLogtape();
255
+ try {
256
+ const middleware = koaLogger({ format: "dev" });
257
+ const ctx = createMockContext({
258
+ method: "POST",
259
+ path: "/api/users",
260
+ status: 201,
261
+ response: { length: 456 },
262
+ });
263
+
264
+ await runMiddleware(middleware, ctx);
265
+
266
+ assertEquals(logs.length, 1);
267
+ const msg = logs[0].rawMessage;
268
+ assert(msg.includes("POST"));
269
+ assert(msg.includes("/api/users"));
270
+ assert(msg.includes("201"));
271
+ assert(msg.includes("ms"));
272
+ assert(msg.includes("456"));
273
+ } finally {
274
+ await cleanup();
275
+ }
276
+ });
277
+
278
+ // ============================================
279
+ // Format Tests - Short
280
+ // ============================================
281
+
282
+ test("koaLogger(): short format includes remote addr", async () => {
283
+ const { logs, cleanup } = await setupLogtape();
284
+ try {
285
+ const middleware = koaLogger({ format: "short" });
286
+ const ctx = createMockContext({
287
+ ip: "192.168.1.1",
288
+ });
289
+
290
+ await runMiddleware(middleware, ctx);
291
+
292
+ assertEquals(logs.length, 1);
293
+ const msg = logs[0].rawMessage;
294
+ assert(msg.includes("192.168.1.1"));
295
+ assert(msg.includes("GET"));
296
+ assert(msg.includes("/test"));
297
+ } finally {
298
+ await cleanup();
299
+ }
300
+ });
301
+
302
+ // ============================================
303
+ // Format Tests - Tiny
304
+ // ============================================
305
+
306
+ test("koaLogger(): tiny format is minimal", async () => {
307
+ const { logs, cleanup } = await setupLogtape();
308
+ try {
309
+ const middleware = koaLogger({ format: "tiny" });
310
+ const ctx = createMockContext({ status: 404 });
311
+
312
+ await runMiddleware(middleware, ctx);
313
+
314
+ assertEquals(logs.length, 1);
315
+ const msg = logs[0].rawMessage;
316
+ assert(msg.includes("GET"));
317
+ assert(msg.includes("/test"));
318
+ assert(msg.includes("404"));
319
+ assert(msg.includes("ms"));
320
+ // Tiny format should NOT include remote addr
321
+ assert(!msg.includes("127.0.0.1"));
322
+ } finally {
323
+ await cleanup();
324
+ }
325
+ });
326
+
327
+ // ============================================
328
+ // Custom Format Function Tests
329
+ // ============================================
330
+
331
+ test("koaLogger(): custom format returning string", async () => {
332
+ const { logs, cleanup } = await setupLogtape();
333
+ try {
334
+ const middleware = koaLogger({
335
+ format: (ctx: KoaContext, _responseTime: number) =>
336
+ `Custom: ${ctx.method} ${ctx.status}`,
337
+ });
338
+ const ctx = createMockContext({ method: "DELETE", status: 204 });
339
+
340
+ await runMiddleware(middleware, ctx);
341
+
342
+ assertEquals(logs.length, 1);
343
+ assertEquals(logs[0].rawMessage, "Custom: DELETE 204");
344
+ } finally {
345
+ await cleanup();
346
+ }
347
+ });
348
+
349
+ test("koaLogger(): custom format returning object", async () => {
350
+ const { logs, cleanup } = await setupLogtape();
351
+ try {
352
+ const middleware = koaLogger({
353
+ format: (ctx: KoaContext, responseTime: number) => ({
354
+ customMethod: ctx.method,
355
+ customStatus: ctx.status,
356
+ customDuration: responseTime,
357
+ }),
358
+ });
359
+ const ctx = createMockContext({ method: "PATCH", status: 202 });
360
+
361
+ await runMiddleware(middleware, ctx);
362
+
363
+ assertEquals(logs.length, 1);
364
+ assertEquals(logs[0].properties.customMethod, "PATCH");
365
+ assertEquals(logs[0].properties.customStatus, 202);
366
+ assertExists(logs[0].properties.customDuration);
367
+ } finally {
368
+ await cleanup();
369
+ }
370
+ });
371
+
372
+ // ============================================
373
+ // Skip Function Tests
374
+ // ============================================
375
+
376
+ test("koaLogger(): skip function prevents logging when returns true", async () => {
377
+ const { logs, cleanup } = await setupLogtape();
378
+ try {
379
+ const middleware = koaLogger({
380
+ skip: () => true,
381
+ });
382
+ const ctx = createMockContext();
383
+
384
+ await runMiddleware(middleware, ctx);
385
+
386
+ assertEquals(logs.length, 0);
387
+ } finally {
388
+ await cleanup();
389
+ }
390
+ });
391
+
392
+ test("koaLogger(): skip function allows logging when returns false", async () => {
393
+ const { logs, cleanup } = await setupLogtape();
394
+ try {
395
+ const middleware = koaLogger({
396
+ skip: () => false,
397
+ });
398
+ const ctx = createMockContext();
399
+
400
+ await runMiddleware(middleware, ctx);
401
+
402
+ assertEquals(logs.length, 1);
403
+ } finally {
404
+ await cleanup();
405
+ }
406
+ });
407
+
408
+ test("koaLogger(): skip function receives context", async () => {
409
+ const { logs, cleanup } = await setupLogtape();
410
+ try {
411
+ const middleware = koaLogger({
412
+ skip: (ctx: KoaContext) => ctx.path === "/health",
413
+ });
414
+
415
+ // Health endpoint should be skipped
416
+ const healthCtx = createMockContext({ path: "/health" });
417
+ await runMiddleware(middleware, healthCtx);
418
+ assertEquals(logs.length, 0);
419
+
420
+ // Other endpoints should be logged
421
+ const testCtx = createMockContext({ path: "/test" });
422
+ await runMiddleware(middleware, testCtx);
423
+ assertEquals(logs.length, 1);
424
+ } finally {
425
+ await cleanup();
426
+ }
427
+ });
428
+
429
+ // ============================================
430
+ // logRequest (Immediate) Mode Tests
431
+ // ============================================
432
+
433
+ test("koaLogger(): logRequest mode logs at request start", async () => {
434
+ const { logs, cleanup } = await setupLogtape();
435
+ try {
436
+ const middleware = koaLogger({ logRequest: true });
437
+ const ctx = createMockContext();
438
+
439
+ await runMiddleware(middleware, ctx);
440
+
441
+ assertEquals(logs.length, 1);
442
+ assertEquals(logs[0].properties.responseTime, 0); // Zero because it's immediate
443
+ } finally {
444
+ await cleanup();
445
+ }
446
+ });
447
+
448
+ test("koaLogger(): non-logRequest mode logs after response", async () => {
449
+ const { logs, cleanup } = await setupLogtape();
450
+ try {
451
+ const middleware = koaLogger({ logRequest: false });
452
+ const ctx = createMockContext();
453
+
454
+ await runMiddleware(middleware, ctx, async () => {
455
+ // Small delay to ensure non-zero response time
456
+ await new Promise((resolve) => setTimeout(resolve, 5));
457
+ });
458
+
459
+ assertEquals(logs.length, 1);
460
+ assert((logs[0].properties.responseTime as number) >= 0);
461
+ } finally {
462
+ await cleanup();
463
+ }
464
+ });
465
+
466
+ // ============================================
467
+ // Request Property Tests
468
+ // ============================================
469
+
470
+ test("koaLogger(): logs correct method", async () => {
471
+ const { logs, cleanup } = await setupLogtape();
472
+ try {
473
+ const middleware = koaLogger();
474
+
475
+ const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
476
+ for (const method of methods) {
477
+ const ctx = createMockContext({ method });
478
+ await runMiddleware(middleware, ctx);
479
+ }
480
+
481
+ assertEquals(logs.length, methods.length);
482
+ for (let i = 0; i < methods.length; i++) {
483
+ assertEquals(logs[i].properties.method, methods[i]);
484
+ }
485
+ } finally {
486
+ await cleanup();
487
+ }
488
+ });
489
+
490
+ test("koaLogger(): logs path correctly", async () => {
491
+ const { logs, cleanup } = await setupLogtape();
492
+ try {
493
+ const middleware = koaLogger();
494
+ const ctx = createMockContext({ path: "/api/v1/users" });
495
+
496
+ await runMiddleware(middleware, ctx);
497
+
498
+ assertEquals(logs.length, 1);
499
+ assertEquals(logs[0].properties.path, "/api/v1/users");
500
+ } finally {
501
+ await cleanup();
502
+ }
503
+ });
504
+
505
+ test("koaLogger(): logs status code", async () => {
506
+ const { logs, cleanup } = await setupLogtape();
507
+ try {
508
+ const middleware = koaLogger();
509
+
510
+ const statusCodes = [200, 201, 301, 400, 404, 500];
511
+ for (const status of statusCodes) {
512
+ const ctx = createMockContext({ status });
513
+ await runMiddleware(middleware, ctx);
514
+ }
515
+
516
+ assertEquals(logs.length, statusCodes.length);
517
+ for (let i = 0; i < statusCodes.length; i++) {
518
+ assertEquals(logs[i].properties.status, statusCodes[i]);
519
+ }
520
+ } finally {
521
+ await cleanup();
522
+ }
523
+ });
524
+
525
+ test("koaLogger(): logs response time as number", async () => {
526
+ const { logs, cleanup } = await setupLogtape();
527
+ try {
528
+ const middleware = koaLogger();
529
+ const ctx = createMockContext();
530
+
531
+ await runMiddleware(middleware, ctx, async () => {
532
+ // Add small delay to ensure non-zero response time
533
+ await new Promise((resolve) => setTimeout(resolve, 10));
534
+ });
535
+
536
+ assertEquals(logs.length, 1);
537
+ assertEquals(typeof logs[0].properties.responseTime, "number");
538
+ assert((logs[0].properties.responseTime as number) >= 0);
539
+ } finally {
540
+ await cleanup();
541
+ }
542
+ });
543
+
544
+ test("koaLogger(): logs content-length when present", async () => {
545
+ const { logs, cleanup } = await setupLogtape();
546
+ try {
547
+ const middleware = koaLogger();
548
+ const ctx = createMockContext({
549
+ response: { length: 1024 },
550
+ });
551
+
552
+ await runMiddleware(middleware, ctx);
553
+
554
+ assertEquals(logs.length, 1);
555
+ assertEquals(logs[0].properties.contentLength, 1024);
556
+ } finally {
557
+ await cleanup();
558
+ }
559
+ });
560
+
561
+ test("koaLogger(): logs undefined contentLength when not set", async () => {
562
+ const { logs, cleanup } = await setupLogtape();
563
+ try {
564
+ const middleware = koaLogger();
565
+ const ctx = createMockContext({
566
+ response: { length: undefined },
567
+ });
568
+
569
+ await runMiddleware(middleware, ctx);
570
+
571
+ assertEquals(logs.length, 1);
572
+ assertEquals(logs[0].properties.contentLength, undefined);
573
+ } finally {
574
+ await cleanup();
575
+ }
576
+ });
577
+
578
+ test("koaLogger(): logs remote address from ctx.ip", async () => {
579
+ const { logs, cleanup } = await setupLogtape();
580
+ try {
581
+ const middleware = koaLogger();
582
+ const ctx = createMockContext({
583
+ ip: "10.0.0.1",
584
+ });
585
+
586
+ await runMiddleware(middleware, ctx);
587
+
588
+ assertEquals(logs.length, 1);
589
+ assertEquals(logs[0].properties.remoteAddr, "10.0.0.1");
590
+ } finally {
591
+ await cleanup();
592
+ }
593
+ });
594
+
595
+ test("koaLogger(): logs user agent", async () => {
596
+ const { logs, cleanup } = await setupLogtape();
597
+ try {
598
+ const middleware = koaLogger();
599
+ const ctx = createMockContext({
600
+ get: (field: string) => {
601
+ if (field.toLowerCase() === "user-agent") return "TestClient/1.0";
602
+ return "";
603
+ },
604
+ });
605
+
606
+ await runMiddleware(middleware, ctx);
607
+
608
+ assertEquals(logs.length, 1);
609
+ assertEquals(logs[0].properties.userAgent, "TestClient/1.0");
610
+ } finally {
611
+ await cleanup();
612
+ }
613
+ });
614
+
615
+ test("koaLogger(): logs referrer", async () => {
616
+ const { logs, cleanup } = await setupLogtape();
617
+ try {
618
+ const middleware = koaLogger();
619
+ const ctx = createMockContext({
620
+ get: (field: string) => {
621
+ if (field.toLowerCase() === "referer") {
622
+ return "https://example.com/page";
623
+ }
624
+ if (field.toLowerCase() === "referrer") {
625
+ return "https://example.com/page";
626
+ }
627
+ return "";
628
+ },
629
+ });
630
+
631
+ await runMiddleware(middleware, ctx);
632
+
633
+ assertEquals(logs.length, 1);
634
+ assertEquals(logs[0].properties.referrer, "https://example.com/page");
635
+ } finally {
636
+ await cleanup();
637
+ }
638
+ });
639
+
640
+ // ============================================
641
+ // Multiple Requests Tests
642
+ // ============================================
643
+
644
+ test("koaLogger(): handles multiple sequential requests", async () => {
645
+ const { logs, cleanup } = await setupLogtape();
646
+ try {
647
+ const middleware = koaLogger();
648
+
649
+ for (let i = 0; i < 5; i++) {
650
+ const ctx = createMockContext({
651
+ path: `/path/${i}`,
652
+ url: `/path/${i}`,
653
+ status: 200 + i,
654
+ });
655
+ await runMiddleware(middleware, ctx);
656
+ }
657
+
658
+ assertEquals(logs.length, 5);
659
+ for (let i = 0; i < 5; i++) {
660
+ assertEquals(logs[i].properties.path, `/path/${i}`);
661
+ assertEquals(logs[i].properties.status, 200 + i);
662
+ }
663
+ } finally {
664
+ await cleanup();
665
+ }
666
+ });
667
+
668
+ // ============================================
669
+ // Edge Cases
670
+ // ============================================
671
+
672
+ test("koaLogger(): handles missing user-agent", async () => {
673
+ const { logs, cleanup } = await setupLogtape();
674
+ try {
675
+ const middleware = koaLogger();
676
+ const ctx = createMockContext({
677
+ get: () => "",
678
+ });
679
+
680
+ await runMiddleware(middleware, ctx);
681
+
682
+ assertEquals(logs.length, 1);
683
+ assertEquals(logs[0].properties.userAgent, undefined);
684
+ } finally {
685
+ await cleanup();
686
+ }
687
+ });
688
+
689
+ test("koaLogger(): handles missing referrer", async () => {
690
+ const { logs, cleanup } = await setupLogtape();
691
+ try {
692
+ const middleware = koaLogger();
693
+ const ctx = createMockContext({
694
+ get: (field: string) => {
695
+ if (field.toLowerCase() === "user-agent") return "test-agent";
696
+ return "";
697
+ },
698
+ });
699
+
700
+ await runMiddleware(middleware, ctx);
701
+
702
+ assertEquals(logs.length, 1);
703
+ assertEquals(logs[0].properties.referrer, undefined);
704
+ } finally {
705
+ await cleanup();
706
+ }
707
+ });
708
+
709
+ test("koaLogger(): handles query parameters in url", async () => {
710
+ const { logs, cleanup } = await setupLogtape();
711
+ try {
712
+ const middleware = koaLogger();
713
+ const ctx = createMockContext({
714
+ path: "/search",
715
+ url: "/search?q=test&limit=10",
716
+ });
717
+
718
+ await runMiddleware(middleware, ctx);
719
+
720
+ assertEquals(logs.length, 1);
721
+ assertEquals(logs[0].properties.path, "/search");
722
+ assert((logs[0].properties.url as string).includes("q=test"));
723
+ assert((logs[0].properties.url as string).includes("limit=10"));
724
+ } finally {
725
+ await cleanup();
726
+ }
727
+ });
package/src/mod.ts ADDED
@@ -0,0 +1,392 @@
1
+ import { getLogger, type LogLevel } from "@logtape/logtape";
2
+
3
+ export type { LogLevel } from "@logtape/logtape";
4
+
5
+ /**
6
+ * Minimal Koa Context interface for compatibility across Koa 2.x and 3.x.
7
+ *
8
+ * This interface includes common aliases available on the Koa context object.
9
+ * See https://koajs.com/#context for the full API.
10
+ *
11
+ * @since 1.3.0
12
+ */
13
+ export interface KoaContext {
14
+ /** HTTP request method (alias for ctx.request.method) */
15
+ method: string;
16
+ /** Request URL (alias for ctx.request.url) */
17
+ url: string;
18
+ /** Request pathname (alias for ctx.request.path) */
19
+ path: string;
20
+ /** HTTP response status code (alias for ctx.response.status) */
21
+ status: number;
22
+ /** Remote client IP address (alias for ctx.request.ip) */
23
+ ip: string;
24
+ /** Koa Response object */
25
+ response: {
26
+ length?: number;
27
+ };
28
+ /**
29
+ * Get a request header field value (case-insensitive).
30
+ * @param field The header field name.
31
+ * @returns The header value, or an empty string if not present.
32
+ */
33
+ get(field: string): string;
34
+ }
35
+
36
+ /**
37
+ * Koa middleware function type.
38
+ * @since 1.3.0
39
+ */
40
+ export type KoaMiddleware = (
41
+ ctx: KoaContext,
42
+ next: () => Promise<void>,
43
+ ) => Promise<void>;
44
+
45
+ /**
46
+ * Predefined log format names compatible with Morgan.
47
+ * @since 1.3.0
48
+ */
49
+ export type PredefinedFormat = "combined" | "common" | "dev" | "short" | "tiny";
50
+
51
+ /**
52
+ * Custom format function for request logging.
53
+ *
54
+ * @param ctx The Koa context object.
55
+ * @param responseTime The response time in milliseconds.
56
+ * @returns A string message or an object with structured properties.
57
+ * @since 1.3.0
58
+ */
59
+ export type FormatFunction = (
60
+ ctx: KoaContext,
61
+ responseTime: number,
62
+ ) => string | Record<string, unknown>;
63
+
64
+ /**
65
+ * Structured log properties for HTTP requests.
66
+ * @since 1.3.0
67
+ */
68
+ export interface RequestLogProperties {
69
+ /** HTTP request method */
70
+ method: string;
71
+ /** Request URL */
72
+ url: string;
73
+ /** Request path */
74
+ path: string;
75
+ /** HTTP response status code */
76
+ status: number;
77
+ /** Response time in milliseconds */
78
+ responseTime: number;
79
+ /** Response content-length */
80
+ contentLength: number | undefined;
81
+ /** Remote client address */
82
+ remoteAddr: string | undefined;
83
+ /** User-Agent header value */
84
+ userAgent: string | undefined;
85
+ /** Referrer header value */
86
+ referrer: string | undefined;
87
+ }
88
+
89
+ /**
90
+ * Options for configuring the Koa LogTape middleware.
91
+ * @since 1.3.0
92
+ */
93
+ export interface KoaLogTapeOptions {
94
+ /**
95
+ * The LogTape category to use for logging.
96
+ * @default ["koa"]
97
+ */
98
+ readonly category?: string | readonly string[];
99
+
100
+ /**
101
+ * The log level to use for request logging.
102
+ * @default "info"
103
+ */
104
+ readonly level?: LogLevel;
105
+
106
+ /**
107
+ * The format for log output.
108
+ * Can be a predefined format name or a custom format function.
109
+ *
110
+ * Predefined formats:
111
+ * - `"combined"` - Apache Combined Log Format (structured, default)
112
+ * - `"common"` - Apache Common Log Format (structured, no referrer/userAgent)
113
+ * - `"dev"` - Concise colored output for development (string)
114
+ * - `"short"` - Shorter than common (string)
115
+ * - `"tiny"` - Minimal output (string)
116
+ *
117
+ * @default "combined"
118
+ */
119
+ readonly format?: PredefinedFormat | FormatFunction;
120
+
121
+ /**
122
+ * Function to determine whether logging should be skipped.
123
+ * Return `true` to skip logging for a request.
124
+ *
125
+ * @example Skip logging for health check endpoint
126
+ * ```typescript
127
+ * app.use(koaLogger({
128
+ * skip: (ctx) => ctx.path === "/health",
129
+ * }));
130
+ * ```
131
+ *
132
+ * @default () => false
133
+ */
134
+ readonly skip?: (ctx: KoaContext) => boolean;
135
+
136
+ /**
137
+ * If `true`, logs are written immediately when the request is received.
138
+ * If `false` (default), logs are written after the response is sent.
139
+ *
140
+ * Note: When `logRequest` is `true`, response-related properties
141
+ * (status, responseTime, contentLength) will not be available.
142
+ *
143
+ * @default false
144
+ */
145
+ readonly logRequest?: boolean;
146
+ }
147
+
148
+ /**
149
+ * Get referrer from request headers.
150
+ * Returns undefined if the header is not present or empty.
151
+ */
152
+ function getReferrer(ctx: KoaContext): string | undefined {
153
+ const referrer = ctx.get("referrer") || ctx.get("referer");
154
+ return referrer !== "" ? referrer : undefined;
155
+ }
156
+
157
+ /**
158
+ * Get user agent from request headers.
159
+ * Returns undefined if the header is not present or empty.
160
+ */
161
+ function getUserAgent(ctx: KoaContext): string | undefined {
162
+ const userAgent = ctx.get("user-agent");
163
+ return userAgent !== "" ? userAgent : undefined;
164
+ }
165
+
166
+ /**
167
+ * Get remote address from context.
168
+ * Returns undefined if not available.
169
+ */
170
+ function getRemoteAddr(ctx: KoaContext): string | undefined {
171
+ return ctx.ip !== "" ? ctx.ip : undefined;
172
+ }
173
+
174
+ /**
175
+ * Get content length from response.
176
+ */
177
+ function getContentLength(ctx: KoaContext): number | undefined {
178
+ return ctx.response.length;
179
+ }
180
+
181
+ /**
182
+ * Build structured log properties from context.
183
+ */
184
+ function buildProperties(
185
+ ctx: KoaContext,
186
+ responseTime: number,
187
+ ): RequestLogProperties {
188
+ return {
189
+ method: ctx.method,
190
+ url: ctx.url,
191
+ path: ctx.path,
192
+ status: ctx.status,
193
+ responseTime,
194
+ contentLength: getContentLength(ctx),
195
+ remoteAddr: getRemoteAddr(ctx),
196
+ userAgent: getUserAgent(ctx),
197
+ referrer: getReferrer(ctx),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Combined format (Apache Combined Log Format).
203
+ * Returns all structured properties.
204
+ */
205
+ function formatCombined(
206
+ ctx: KoaContext,
207
+ responseTime: number,
208
+ ): Record<string, unknown> {
209
+ return { ...buildProperties(ctx, responseTime) };
210
+ }
211
+
212
+ /**
213
+ * Common format (Apache Common Log Format).
214
+ * Like combined but without referrer and userAgent.
215
+ */
216
+ function formatCommon(
217
+ ctx: KoaContext,
218
+ responseTime: number,
219
+ ): Record<string, unknown> {
220
+ const props = buildProperties(ctx, responseTime);
221
+ const { referrer: _referrer, userAgent: _userAgent, ...rest } = props;
222
+ return rest;
223
+ }
224
+
225
+ /**
226
+ * Dev format (colored output for development).
227
+ * :method :path :status :response-time ms - :res[content-length]
228
+ */
229
+ function formatDev(
230
+ ctx: KoaContext,
231
+ responseTime: number,
232
+ ): string {
233
+ const contentLength = getContentLength(ctx) ?? "-";
234
+ return `${ctx.method} ${ctx.path} ${ctx.status} ${
235
+ responseTime.toFixed(3)
236
+ } ms - ${contentLength}`;
237
+ }
238
+
239
+ /**
240
+ * Short format.
241
+ * :remote-addr :method :url :status :res[content-length] - :response-time ms
242
+ */
243
+ function formatShort(
244
+ ctx: KoaContext,
245
+ responseTime: number,
246
+ ): string {
247
+ const remoteAddr = getRemoteAddr(ctx) ?? "-";
248
+ const contentLength = getContentLength(ctx) ?? "-";
249
+ return `${remoteAddr} ${ctx.method} ${ctx.url} ${ctx.status} ${contentLength} - ${
250
+ responseTime.toFixed(3)
251
+ } ms`;
252
+ }
253
+
254
+ /**
255
+ * Tiny format (minimal output).
256
+ * :method :path :status :res[content-length] - :response-time ms
257
+ */
258
+ function formatTiny(
259
+ ctx: KoaContext,
260
+ responseTime: number,
261
+ ): string {
262
+ const contentLength = getContentLength(ctx) ?? "-";
263
+ return `${ctx.method} ${ctx.path} ${ctx.status} ${contentLength} - ${
264
+ responseTime.toFixed(3)
265
+ } ms`;
266
+ }
267
+
268
+ /**
269
+ * Map of predefined format functions.
270
+ */
271
+ const predefinedFormats: Record<PredefinedFormat, FormatFunction> = {
272
+ combined: formatCombined,
273
+ common: formatCommon,
274
+ dev: formatDev,
275
+ short: formatShort,
276
+ tiny: formatTiny,
277
+ };
278
+
279
+ /**
280
+ * Normalize category to array format.
281
+ */
282
+ function normalizeCategory(
283
+ category: string | readonly string[],
284
+ ): readonly string[] {
285
+ return typeof category === "string" ? [category] : category;
286
+ }
287
+
288
+ /**
289
+ * Creates Koa middleware for HTTP request logging using LogTape.
290
+ *
291
+ * This middleware provides Morgan-compatible request logging with LogTape
292
+ * as the backend, supporting structured logging and customizable formats.
293
+ * It serves as an alternative to koa-logger with structured logging support.
294
+ *
295
+ * @example Basic usage
296
+ * ```typescript
297
+ * import Koa from "koa";
298
+ * import { configure, getConsoleSink } from "@logtape/logtape";
299
+ * import { koaLogger } from "@logtape/koa";
300
+ *
301
+ * await configure({
302
+ * sinks: { console: getConsoleSink() },
303
+ * loggers: [
304
+ * { category: ["koa"], sinks: ["console"], lowestLevel: "info" }
305
+ * ],
306
+ * });
307
+ *
308
+ * const app = new Koa();
309
+ * app.use(koaLogger());
310
+ *
311
+ * app.use((ctx) => {
312
+ * ctx.body = { hello: "world" };
313
+ * });
314
+ *
315
+ * app.listen(3000);
316
+ * ```
317
+ *
318
+ * @example With custom options
319
+ * ```typescript
320
+ * app.use(koaLogger({
321
+ * category: ["myapp", "http"],
322
+ * level: "debug",
323
+ * format: "dev",
324
+ * skip: (ctx) => ctx.path === "/health",
325
+ * }));
326
+ * ```
327
+ *
328
+ * @example With custom format function
329
+ * ```typescript
330
+ * app.use(koaLogger({
331
+ * format: (ctx, responseTime) => ({
332
+ * method: ctx.method,
333
+ * path: ctx.path,
334
+ * status: ctx.status,
335
+ * duration: responseTime,
336
+ * }),
337
+ * }));
338
+ * ```
339
+ *
340
+ * @param options Configuration options for the middleware.
341
+ * @returns Koa middleware function.
342
+ * @since 1.3.0
343
+ */
344
+ export function koaLogger(
345
+ options: KoaLogTapeOptions = {},
346
+ ): KoaMiddleware {
347
+ const category = normalizeCategory(options.category ?? ["koa"]);
348
+ const logger = getLogger(category);
349
+ const level = options.level ?? "info";
350
+ const formatOption = options.format ?? "combined";
351
+ const skip = options.skip ?? (() => false);
352
+ const logRequest = options.logRequest ?? false;
353
+
354
+ // Resolve format function
355
+ const formatFn: FormatFunction = typeof formatOption === "string"
356
+ ? predefinedFormats[formatOption]
357
+ : formatOption;
358
+
359
+ const logMethod = logger[level].bind(logger);
360
+
361
+ return async (ctx: KoaContext, next: () => Promise<void>): Promise<void> => {
362
+ const startTime = Date.now();
363
+
364
+ // For immediate logging, log when request arrives
365
+ if (logRequest) {
366
+ if (!skip(ctx)) {
367
+ const result = formatFn(ctx, 0);
368
+ if (typeof result === "string") {
369
+ logMethod(result);
370
+ } else {
371
+ logMethod("{method} {url}", result);
372
+ }
373
+ }
374
+ await next();
375
+ return;
376
+ }
377
+
378
+ // Log after response is sent
379
+ await next();
380
+
381
+ if (skip(ctx)) return;
382
+
383
+ const responseTime = Date.now() - startTime;
384
+ const result = formatFn(ctx, responseTime);
385
+
386
+ if (typeof result === "string") {
387
+ logMethod(result);
388
+ } else {
389
+ logMethod("{method} {url} {status} - {responseTime} ms", result);
390
+ }
391
+ };
392
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: "src/mod.ts",
5
+ dts: {
6
+ sourcemap: true,
7
+ },
8
+ format: ["esm", "cjs"],
9
+ platform: "node",
10
+ unbundle: true,
11
+ });