@swarmmachina/swm-core 1.1.4 → 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
 
@@ -327,6 +327,16 @@ const page = ctx.query('page') // ?page=1
327
327
 
328
328
  **Returns:** `string`
329
329
 
330
+ ##### `ctx.fullQuery()`
331
+
332
+ Get full raw query string.
333
+
334
+ ```javascript
335
+ const q = ctx.fullQuery() // page=1&limit=20
336
+ ```
337
+
338
+ **Returns:** `string`
339
+
330
340
  ##### `ctx.param(indexOrName)`
331
341
 
332
342
  Get URL parameter by index or name (for pattern matching in native routing).
@@ -398,7 +408,7 @@ ctx.status(201).send({ created: true })
398
408
 
399
409
  ##### `ctx.setHeader(key, value)`
400
410
 
401
- 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.
402
412
 
403
413
  ```javascript
404
414
  ctx.setHeader('x-header-any', 'string-value').status(201).send({ created: true })
@@ -406,6 +416,37 @@ ctx.setHeader('x-header-any', 'string-value').status(201).send({ created: true }
406
416
 
407
417
  **Returns:** `HttpContext`
408
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
+
409
450
  ##### `ctx.send(data)`
410
451
 
411
452
  Send response with automatic content-type detection.
@@ -419,12 +460,59 @@ ctx.send(null) // 204 No Content
419
460
 
420
461
  **Supported types:** Object, String, Buffer, null, undefined
421
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
+
422
503
  ##### `ctx.reply(status, headers, body)`
423
504
 
424
- 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.
425
506
 
426
507
  ```javascript
427
- 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
+ )
428
516
  ```
429
517
 
430
518
  ##### `ctx.stream(readable, [status], [headers])`
@@ -442,10 +530,13 @@ await ctx.stream(stream, 200, { 'content-type': 'video/mp4' })
442
530
 
443
531
  ##### `ctx.startStreaming([status], [headers])`
444
532
 
445
- 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.
446
534
 
447
535
  ```javascript
448
- 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
+ })
449
540
  ```
450
541
 
451
542
  ##### `ctx.write(chunk)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmmachina/swm-core",
3
- "version": "1.1.4",
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
  },
@@ -8,7 +8,8 @@ export default class HttpContext {
8
8
  #url = ''
9
9
  #headersCached = false
10
10
  #headers = {}
11
- #fullQuery = null
11
+ #fullQuery = ''
12
+ #fullQueryCached = false
12
13
  #fullQueryParsed = false
13
14
  #query = {}
14
15
  #params = {}
@@ -122,7 +123,8 @@ export default class HttpContext {
122
123
  this.#method = ''
123
124
  this.#headersCached = false
124
125
  this.#headers = {}
125
- this.#fullQuery = null
126
+ this.#fullQuery = ''
127
+ this.#fullQueryCached = false
126
128
  this.#fullQueryParsed = false
127
129
  this.#query = {}
128
130
  this.#params = {}
@@ -155,7 +157,8 @@ export default class HttpContext {
155
157
  this.#method = ''
156
158
  this.#headersCached = false
157
159
  this.#headers = {}
158
- this.#fullQuery = null
160
+ this.#fullQuery = ''
161
+ this.#fullQueryCached = false
159
162
  this.#fullQueryParsed = false
160
163
  this.#query = {}
161
164
  this.#params = {}
@@ -200,13 +203,14 @@ export default class HttpContext {
200
203
  }
201
204
 
202
205
  cacheQuery() {
203
- if (this.#fullQuery !== null || !this.req) {
206
+ if (this.#fullQueryCached || !this.req) {
204
207
  return
205
208
  }
206
209
 
207
210
  const fullQuery = this.req.getQuery()
208
211
 
209
212
  this.#fullQuery = typeof fullQuery === 'string' ? fullQuery : ''
213
+ this.#fullQueryCached = true
210
214
  this.#fullQueryParsed = false
211
215
  }
212
216
 
@@ -291,6 +295,27 @@ export default class HttpContext {
291
295
  return this.#url
292
296
  }
293
297
 
298
+ /**
299
+ * @returns {string}
300
+ */
301
+ fullQuery() {
302
+ if (this.#fullQueryCached) {
303
+ return this.#fullQuery
304
+ }
305
+
306
+ if (!this.req) {
307
+ return ''
308
+ }
309
+
310
+ const fullQuery = this.req.getQuery()
311
+
312
+ this.#fullQuery = typeof fullQuery === 'string' ? fullQuery : ''
313
+ this.#fullQueryCached = true
314
+ this.#fullQueryParsed = false
315
+
316
+ return this.#fullQuery
317
+ }
318
+
294
319
  /**
295
320
  * @param {string} name
296
321
  * @returns {string|undefined}
@@ -300,7 +325,7 @@ export default class HttpContext {
300
325
  return this.#query[name]
301
326
  }
302
327
 
303
- if (this.#fullQuery !== null) {
328
+ if (this.#fullQueryCached) {
304
329
  this.#parseFullQuery()
305
330
 
306
331
  if (name in this.#query) {
@@ -403,12 +428,61 @@ export default class HttpContext {
403
428
  return this
404
429
  }
405
430
 
406
- 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
+
407
481
  return this
408
482
  }
409
483
 
410
484
  /**
411
- * @param {Record<string, string> | null | undefined} headers
485
+ * @param {Record<string, string | string[]> | null | undefined} headers
412
486
  */
413
487
  setHeaders(headers) {
414
488
  if (this.replied || this.aborted) {
@@ -419,7 +493,7 @@ export default class HttpContext {
419
493
  }
420
494
 
421
495
  /**
422
- * @param {Record<string, string> | null | undefined} headers
496
+ * @param {Record<string, string | string[]> | null | undefined} headers
423
497
  */
424
498
  flushHeaders(headers = null) {
425
499
  this.#flushPendingHeaders(headers)
@@ -427,21 +501,83 @@ export default class HttpContext {
427
501
 
428
502
  /**
429
503
  * @param {string} key
430
- * @param {string} value
504
+ * @param {string | string[] | null | undefined} value
505
+ * @param {boolean} append
431
506
  */
432
- #stageHeader(key, value) {
507
+ #stagePendingHeader(key, value, append) {
433
508
  if (value === undefined || value === null) {
434
509
  return
435
510
  }
436
511
 
437
- const headerName = String(key)
438
- 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
+ }
559
+
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]
439
570
 
440
- this.#pendingHeaders.set(headerName.toLowerCase(), [headerName, headerValue])
571
+ if (typeof cur === 'string') {
572
+ pendingHeader[1] = [cur, headerValue]
573
+ } else {
574
+ cur[cur.length] = headerValue
575
+ }
576
+ }
441
577
  }
442
578
 
443
579
  /**
444
- * @param {Record<string, string> | null | undefined} headers
580
+ * @param {Record<string, string | string[]> | null | undefined} headers
445
581
  */
446
582
  #stageHeaders(headers) {
447
583
  if (!headers) {
@@ -449,29 +585,27 @@ export default class HttpContext {
449
585
  }
450
586
 
451
587
  if (headers === TEXT_PLAIN_HEADER) {
452
- this.#stageHeader('content-type', 'text/plain; charset=utf-8')
588
+ this.#pendingHeaders.set('content-type', ['content-type', 'text/plain; charset=utf-8'])
453
589
  return
454
590
  }
455
591
 
456
592
  if (headers === JSON_HEADER) {
457
- this.#stageHeader('content-type', 'application/json; charset=utf-8')
593
+ this.#pendingHeaders.set('content-type', ['content-type', 'application/json; charset=utf-8'])
458
594
  return
459
595
  }
460
596
 
461
597
  if (headers === OCTET_STREAM_HEADER) {
462
- this.#stageHeader('content-type', 'application/octet-stream')
598
+ this.#pendingHeaders.set('content-type', ['content-type', 'application/octet-stream'])
463
599
  return
464
600
  }
465
601
 
466
602
  for (const key in headers) {
467
- const value = headers[key]
468
-
469
- this.#stageHeader(key, value)
603
+ this.#stagePendingHeader(key, headers[key], false)
470
604
  }
471
605
  }
472
606
 
473
607
  /**
474
- * @param {Record<string, string> | null | undefined} headers
608
+ * @param {Record<string, string | string[]> | null | undefined} headers
475
609
  */
476
610
  #flushPendingHeaders(headers = null) {
477
611
  if (!this.res) {
@@ -483,8 +617,19 @@ export default class HttpContext {
483
617
  this.#stageHeaders(headers)
484
618
  }
485
619
 
486
- for (const [, [key, value]] of this.#pendingHeaders) {
487
- 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
+ }
488
633
  }
489
634
 
490
635
  this.#pendingHeaders.clear()
@@ -557,7 +702,7 @@ export default class HttpContext {
557
702
 
558
703
  /**
559
704
  * @param {number} status
560
- * @param {Record<string,string>} headers
705
+ * @param {Record<string, string | string[]>} headers
561
706
  * @param {string|ArrayBuffer|Uint8Array|Buffer|null|undefined} body
562
707
  */
563
708
  reply(status = 200, headers = null, body = null) {
@@ -585,7 +730,7 @@ export default class HttpContext {
585
730
 
586
731
  /**
587
732
  * @param {number} status
588
- * @param {Record<string,string>} headers
733
+ * @param {Record<string, string | string[]>} headers
589
734
  * @returns {HttpContext}
590
735
  */
591
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) {