@swarmmachina/swm-core 1.1.5 → 1.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
@@ -291,7 +291,7 @@ The `ctx` object passed to the router function:
291
291
 
292
292
  Get request lowercased method.
293
293
 
294
- ```javascriptx
294
+ ```javascript
295
295
  const method = ctx.method()
296
296
  ```
297
297
 
@@ -408,7 +408,7 @@ ctx.status(201).send({ created: true })
408
408
 
409
409
  ##### `ctx.setHeader(key, value)`
410
410
 
411
- Set a response header. Returns context for chaining.
411
+ Set or replace a staged response header. Header names are case-insensitive. Repeated `setHeader()` calls replace previously staged values for the same header. Null or undefined values are silently ignored.
412
412
 
413
413
  ```javascript
414
414
  ctx.setHeader('x-header-any', 'string-value').status(201).send({ created: true })
@@ -416,6 +416,37 @@ ctx.setHeader('x-header-any', 'string-value').status(201).send({ created: true }
416
416
 
417
417
  **Returns:** `HttpContext`
418
418
 
419
+ ##### `ctx.appendHeader(key, value)`
420
+
421
+ Append another staged response header line without replacing existing values. Useful for repeated headers such as `Set-Cookie`. Null or undefined values are silently ignored.
422
+
423
+ ```javascript
424
+ ctx.appendHeader('set-cookie', 'access=...; Path=/; HttpOnly')
425
+ ctx.appendHeader('set-cookie', 'refresh=...; Path=/refresh; HttpOnly')
426
+ ```
427
+
428
+ **Returns:** `HttpContext`
429
+
430
+ ##### `ctx.setHeaders(headers)`
431
+
432
+ Set multiple response headers at once. Equivalent to calling `setHeader()` for each key. Header values may be strings or arrays of strings.
433
+
434
+ ```javascript
435
+ ctx.setHeaders({
436
+ 'x-request-id': '123',
437
+ 'cache-control': 'no-cache',
438
+ 'set-cookie': ['a=1; Path=/', 'b=2; Path=/refresh']
439
+ })
440
+ ```
441
+
442
+ ##### `ctx.flushHeaders([headers])`
443
+
444
+ Flush all staged headers (and optionally stage additional ones) to the underlying response. Called automatically by `reply()` and `startStreaming()` — only needed for advanced use cases.
445
+
446
+ ```javascript
447
+ ctx.flushHeaders({ 'x-extra': 'value' })
448
+ ```
449
+
419
450
  ##### `ctx.send(data)`
420
451
 
421
452
  Send response with automatic content-type detection.
@@ -429,12 +460,59 @@ ctx.send(null) // 204 No Content
429
460
 
430
461
  **Supported types:** Object, String, Buffer, null, undefined
431
462
 
463
+ ##### `ctx.sendJson(data, [status])`
464
+
465
+ Send a JSON response with explicit status code. Defaults to `200`.
466
+
467
+ ```javascript
468
+ ctx.sendJson({ users: [] })
469
+ ctx.sendJson({ error: 'Not found' }, 404)
470
+ ```
471
+
472
+ ##### `ctx.sendText(text, [status])`
473
+
474
+ Send a plain text response with explicit status code. Defaults to `200`.
475
+
476
+ ```javascript
477
+ ctx.sendText('OK')
478
+ ctx.sendText('Created', 201)
479
+ ```
480
+
481
+ ##### `ctx.sendBuffer(buffer, [status])`
482
+
483
+ Send a binary response with explicit status code. Defaults to `200`.
484
+
485
+ ```javascript
486
+ ctx.sendBuffer(Buffer.from('data'))
487
+ ctx.sendBuffer(imageBuffer, 201)
488
+ ```
489
+
490
+ ##### `ctx.sendError(error)`
491
+
492
+ Send an error response. If `error.status` is a finite number, uses that as the HTTP status with `error.message` as the body. Otherwise responds with `500 Internal Server Error`.
493
+
494
+ ```javascript
495
+ ctx.sendError(new Error('Something broke'))
496
+
497
+ // With custom status
498
+ const err = new Error('Not found')
499
+ err.status = 404
500
+ ctx.sendError(err)
501
+ ```
502
+
432
503
  ##### `ctx.reply(status, headers, body)`
433
504
 
434
- Send response with full control over status, headers, and body.
505
+ Send response with full control over status, headers, and body. Header values may be strings or arrays of strings. Array values are written as separate header lines.
435
506
 
436
507
  ```javascript
437
- ctx.reply(200, { 'content-type': 'application/json' }, '{"ok":true}')
508
+ ctx.reply(
509
+ 200,
510
+ {
511
+ 'content-type': 'application/json',
512
+ 'set-cookie': ['a=1; Path=/', 'b=2; Path=/refresh']
513
+ },
514
+ '{"ok":true}'
515
+ )
438
516
  ```
439
517
 
440
518
  ##### `ctx.stream(readable, [status], [headers])`
@@ -452,10 +530,13 @@ await ctx.stream(stream, 200, { 'content-type': 'video/mp4' })
452
530
 
453
531
  ##### `ctx.startStreaming([status], [headers])`
454
532
 
455
- Start streaming response manually (for advanced use cases).
533
+ Start streaming response manually (for advanced use cases). Header values may be strings or arrays of strings.
456
534
 
457
535
  ```javascript
458
- ctx.startStreaming(200, { 'content-type': 'text/plain' })
536
+ ctx.startStreaming(200, {
537
+ 'content-type': 'text/plain',
538
+ 'set-cookie': ['a=1; Path=/', 'b=2; Path=/refresh']
539
+ })
459
540
  ```
460
541
 
461
542
  ##### `ctx.write(chunk)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmmachina/swm-core",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "Zero-dependency high-performance HTTP/WebSocket server built on uWebSockets.js",
5
5
  "keywords": [
6
6
  "http",
@@ -44,6 +44,8 @@
44
44
  "test:e2e:ws": "node --test tests/e2e/ws/*.test.js",
45
45
  "bench": "node ./benchmark/bench.js --test base --runs 1 --warmup 10 --sample-ms 250 --v8prof true",
46
46
  "bench:core": "node ./benchmark/bench.js --test base --runs 1 --warmup 10 --sample-ms 250 --v8prof true --fw core",
47
+ "bench:headers": "node ./benchmark/bench.js --test headers --runs 1 --warmup 10 --sample-ms 250 --v8prof true",
48
+ "bench:headers:core": "node ./benchmark/bench.js --test headers --runs 1 --warmup 10 --sample-ms 250 --v8prof true --fw core",
47
49
  "prepublishOnly": "npm run fix && npm test",
48
50
  "release": "npm run check && npm test && npm publish"
49
51
  },
@@ -428,12 +428,61 @@ export default class HttpContext {
428
428
  return this
429
429
  }
430
430
 
431
- this.#stageHeader(key, value)
431
+ if (typeof key !== 'string') {
432
+ throw new TypeError('Header name must be a string')
433
+ }
434
+
435
+ if (value === undefined || value === null) {
436
+ return this
437
+ }
438
+
439
+ const headerKey = key.toLowerCase()
440
+
441
+ this.#pendingHeaders.set(headerKey, [key, `${value}`])
442
+ return this
443
+ }
444
+
445
+ /**
446
+ * @param {string} key
447
+ * @param {string} value
448
+ * @returns {HttpContext}
449
+ */
450
+ appendHeader(key, value) {
451
+ if (this.replied || this.aborted) {
452
+ return this
453
+ }
454
+
455
+ if (typeof key !== 'string') {
456
+ throw new TypeError('Header name must be a string')
457
+ }
458
+
459
+ if (value === undefined || value === null) {
460
+ return this
461
+ }
462
+
463
+ const headerKey = key.toLowerCase()
464
+ const headerValue = `${value}`
465
+ const pendingHeader = this.#pendingHeaders.get(headerKey)
466
+
467
+ if (!pendingHeader) {
468
+ this.#pendingHeaders.set(headerKey, [key, headerValue])
469
+ return this
470
+ }
471
+
472
+ pendingHeader[0] = key
473
+ const cur = pendingHeader[1]
474
+
475
+ if (typeof cur === 'string') {
476
+ pendingHeader[1] = [cur, headerValue]
477
+ } else {
478
+ cur[cur.length] = headerValue
479
+ }
480
+
432
481
  return this
433
482
  }
434
483
 
435
484
  /**
436
- * @param {Record<string, string> | null | undefined} headers
485
+ * @param {Record<string, string | string[]> | null | undefined} headers
437
486
  */
438
487
  setHeaders(headers) {
439
488
  if (this.replied || this.aborted) {
@@ -444,7 +493,7 @@ export default class HttpContext {
444
493
  }
445
494
 
446
495
  /**
447
- * @param {Record<string, string> | null | undefined} headers
496
+ * @param {Record<string, string | string[]> | null | undefined} headers
448
497
  */
449
498
  flushHeaders(headers = null) {
450
499
  this.#flushPendingHeaders(headers)
@@ -452,21 +501,83 @@ export default class HttpContext {
452
501
 
453
502
  /**
454
503
  * @param {string} key
455
- * @param {string} value
504
+ * @param {string | string[] | null | undefined} value
505
+ * @param {boolean} append
456
506
  */
457
- #stageHeader(key, value) {
507
+ #stagePendingHeader(key, value, append) {
458
508
  if (value === undefined || value === null) {
459
509
  return
460
510
  }
461
511
 
462
- const headerName = String(key)
463
- const headerValue = String(value)
512
+ const headerKey = key.toLowerCase()
513
+
514
+ if (!Array.isArray(value)) {
515
+ const headerValue = `${value}`
516
+
517
+ if (!append) {
518
+ this.#pendingHeaders.set(headerKey, [key, headerValue])
519
+ return
520
+ }
521
+
522
+ const pendingHeader = this.#pendingHeaders.get(headerKey)
523
+
524
+ if (!pendingHeader) {
525
+ this.#pendingHeaders.set(headerKey, [key, headerValue])
526
+ return
527
+ }
528
+
529
+ pendingHeader[0] = key
530
+ const cur = pendingHeader[1]
531
+
532
+ if (typeof cur === 'string') {
533
+ pendingHeader[1] = [cur, headerValue]
534
+ } else {
535
+ cur[cur.length] = headerValue
536
+ }
537
+
538
+ return
539
+ }
540
+
541
+ this.#stagePendingHeaderArray(key, headerKey, value, append)
542
+ }
543
+
544
+ /**
545
+ * @param {string} key
546
+ * @param {string} headerKey
547
+ * @param {string[]} value
548
+ * @param {boolean} append
549
+ */
550
+ #stagePendingHeaderArray(key, headerKey, value, append) {
551
+ let pendingHeader = append ? this.#pendingHeaders.get(headerKey) : null
552
+
553
+ for (let i = 0, len = value.length; i < len; i++) {
554
+ const entry = value[i]
555
+
556
+ if (entry === undefined || entry === null) {
557
+ continue
558
+ }
464
559
 
465
- this.#pendingHeaders.set(headerName.toLowerCase(), [headerName, headerValue])
560
+ const headerValue = `${entry}`
561
+
562
+ if (!pendingHeader) {
563
+ pendingHeader = [key, headerValue]
564
+ this.#pendingHeaders.set(headerKey, pendingHeader)
565
+ continue
566
+ }
567
+
568
+ pendingHeader[0] = key
569
+ const cur = pendingHeader[1]
570
+
571
+ if (typeof cur === 'string') {
572
+ pendingHeader[1] = [cur, headerValue]
573
+ } else {
574
+ cur[cur.length] = headerValue
575
+ }
576
+ }
466
577
  }
467
578
 
468
579
  /**
469
- * @param {Record<string, string> | null | undefined} headers
580
+ * @param {Record<string, string | string[]> | null | undefined} headers
470
581
  */
471
582
  #stageHeaders(headers) {
472
583
  if (!headers) {
@@ -474,29 +585,27 @@ export default class HttpContext {
474
585
  }
475
586
 
476
587
  if (headers === TEXT_PLAIN_HEADER) {
477
- this.#stageHeader('content-type', 'text/plain; charset=utf-8')
588
+ this.#pendingHeaders.set('content-type', ['content-type', 'text/plain; charset=utf-8'])
478
589
  return
479
590
  }
480
591
 
481
592
  if (headers === JSON_HEADER) {
482
- this.#stageHeader('content-type', 'application/json; charset=utf-8')
593
+ this.#pendingHeaders.set('content-type', ['content-type', 'application/json; charset=utf-8'])
483
594
  return
484
595
  }
485
596
 
486
597
  if (headers === OCTET_STREAM_HEADER) {
487
- this.#stageHeader('content-type', 'application/octet-stream')
598
+ this.#pendingHeaders.set('content-type', ['content-type', 'application/octet-stream'])
488
599
  return
489
600
  }
490
601
 
491
602
  for (const key in headers) {
492
- const value = headers[key]
493
-
494
- this.#stageHeader(key, value)
603
+ this.#stagePendingHeader(key, headers[key], false)
495
604
  }
496
605
  }
497
606
 
498
607
  /**
499
- * @param {Record<string, string> | null | undefined} headers
608
+ * @param {Record<string, string | string[]> | null | undefined} headers
500
609
  */
501
610
  #flushPendingHeaders(headers = null) {
502
611
  if (!this.res) {
@@ -508,8 +617,19 @@ export default class HttpContext {
508
617
  this.#stageHeaders(headers)
509
618
  }
510
619
 
511
- for (const [, [key, value]] of this.#pendingHeaders) {
512
- this.res.writeHeader(key, value)
620
+ for (const [, pendingHeader] of this.#pendingHeaders) {
621
+ const headerValue = pendingHeader[1]
622
+
623
+ if (typeof headerValue === 'string') {
624
+ this.res.writeHeader(pendingHeader[0], headerValue)
625
+ continue
626
+ }
627
+
628
+ const name = pendingHeader[0]
629
+
630
+ for (let i = 0, len = headerValue.length; i < len; i++) {
631
+ this.res.writeHeader(name, headerValue[i])
632
+ }
513
633
  }
514
634
 
515
635
  this.#pendingHeaders.clear()
@@ -582,7 +702,7 @@ export default class HttpContext {
582
702
 
583
703
  /**
584
704
  * @param {number} status
585
- * @param {Record<string,string>} headers
705
+ * @param {Record<string, string | string[]>} headers
586
706
  * @param {string|ArrayBuffer|Uint8Array|Buffer|null|undefined} body
587
707
  */
588
708
  reply(status = 200, headers = null, body = null) {
@@ -610,7 +730,7 @@ export default class HttpContext {
610
730
 
611
731
  /**
612
732
  * @param {number} status
613
- * @param {Record<string,string>} headers
733
+ * @param {Record<string, string | string[]>} headers
614
734
  * @returns {HttpContext}
615
735
  */
616
736
  startStreaming(status = 200, headers = null) {
@@ -82,7 +82,7 @@ export default class ResStreamer {
82
82
 
83
83
  /**
84
84
  * @param {number|string} status
85
- * @param {Record<string,string>|null} headers
85
+ * @param {Record<string, string | string[]>|null} headers
86
86
  * @returns {ResStreamer}
87
87
  */
88
88
  begin(status = 200, headers = null) {