@swarmmachina/swm-core 1.1.5 → 1.3.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
@@ -26,6 +26,22 @@ on [uWebSockets.js](https://github.com/uNetworking/uWebSockets.js).
26
26
  npm install @swarmmachina/swm-core
27
27
  ```
28
28
 
29
+ ### Runtime requirements
30
+
31
+ This package depends on the native [uWebSockets.js](https://github.com/uNetworking/uWebSockets.js)
32
+ addon, which imposes a few constraints on the install/runtime environment:
33
+
34
+ - **Node.js 22+** — required by both this package and the bundled uWS prebuilt.
35
+ - **glibc, not musl** — the prebuilt binaries target glibc. Use a `bookworm`/`slim`
36
+ (Debian) base image rather than `alpine` (musl), otherwise the native module fails to load.
37
+ - **Architecture-specific prebuilt** — the loaded binary must match the host CPU
38
+ architecture and Node ABI. When building container images, build for the same
39
+ platform you deploy to (e.g. `--platform linux/amd64`); a binary built on
40
+ arm64 will not run on an amd64 server.
41
+ - **Network access to GitHub at install time** — uWS is pulled from a GitHub tag
42
+ (`uNetworking/uWebSockets.js#v20.67.0`), so `npm install` needs outbound access
43
+ to GitHub. Offline/air-gapped installs require a pre-populated cache or mirror.
44
+
29
45
  ## Quick Start
30
46
 
31
47
  ### Basic HTTP Server
@@ -146,6 +162,7 @@ await server.listen()
146
162
  - **URL Parameters** - Built-in support for `:param` syntax
147
163
  - **Cleaner Code** - Declarative route definitions
148
164
  - **Method-specific** - Automatic HTTP method routing
165
+ - **Wildcard catch-all** - A `{ method: 'any', path: '/*' }` route matches anything not matched by a more specific route (useful as a 404 handler or static-file fallback). Specific routes always win over `/*`.
149
166
 
150
167
  ### WebSocket Server
151
168
 
@@ -208,11 +225,12 @@ new Server(options)
208
225
 
209
226
  **Route Definition (for `routes` array):**
210
227
 
211
- | Property | Type | Description |
212
- | --------- | ---------- | ---------------------------------------------------------------------------------------------- |
213
- | `method` | `String` | HTTP method: `'get'`, `'post'`, `'put'`, `'delete'`, `'patch'`, `'options'`, `'head'`, `'any'` |
214
- | `path` | `String` | URL path pattern (supports `:param` syntax) |
215
- | `handler` | `Function` | Handler function `(ctx) => any \| Promise<any>` |
228
+ | Property | Type | Description |
229
+ | ------------ | ------------------ | ------------------------------------------------------------------------------------------------------ |
230
+ | `method` | `String` | HTTP method: `'get'`, `'post'`, `'put'`, `'delete'`, `'patch'`, `'options'`, `'head'`, `'any'` |
231
+ | `path` | `String` | URL path pattern. Supports `:param` segments and a `/*` wildcard catch-all |
232
+ | `handler` | `Function` | Handler function `(ctx) => any \| Promise<any>` |
233
+ | `preHandler` | `Function`/`Array` | Optional. One function or an array, run before `handler` (see [Route preHandlers](#route-prehandlers)) |
216
234
 
217
235
  **WebSocket Options (`ws` object):**
218
236
 
@@ -291,7 +309,7 @@ The `ctx` object passed to the router function:
291
309
 
292
310
  Get request lowercased method.
293
311
 
294
- ```javascriptx
312
+ ```javascript
295
313
  const method = ctx.method()
296
314
  ```
297
315
 
@@ -408,7 +426,7 @@ ctx.status(201).send({ created: true })
408
426
 
409
427
  ##### `ctx.setHeader(key, value)`
410
428
 
411
- Set a response header. Returns context for chaining.
429
+ 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
430
 
413
431
  ```javascript
414
432
  ctx.setHeader('x-header-any', 'string-value').status(201).send({ created: true })
@@ -416,6 +434,37 @@ ctx.setHeader('x-header-any', 'string-value').status(201).send({ created: true }
416
434
 
417
435
  **Returns:** `HttpContext`
418
436
 
437
+ ##### `ctx.appendHeader(key, value)`
438
+
439
+ 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.
440
+
441
+ ```javascript
442
+ ctx.appendHeader('set-cookie', 'access=...; Path=/; HttpOnly')
443
+ ctx.appendHeader('set-cookie', 'refresh=...; Path=/refresh; HttpOnly')
444
+ ```
445
+
446
+ **Returns:** `HttpContext`
447
+
448
+ ##### `ctx.setHeaders(headers)`
449
+
450
+ Set multiple response headers at once. Equivalent to calling `setHeader()` for each key. Header values may be strings or arrays of strings.
451
+
452
+ ```javascript
453
+ ctx.setHeaders({
454
+ 'x-request-id': '123',
455
+ 'cache-control': 'no-cache',
456
+ 'set-cookie': ['a=1; Path=/', 'b=2; Path=/refresh']
457
+ })
458
+ ```
459
+
460
+ ##### `ctx.flushHeaders([headers])`
461
+
462
+ 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.
463
+
464
+ ```javascript
465
+ ctx.flushHeaders({ 'x-extra': 'value' })
466
+ ```
467
+
419
468
  ##### `ctx.send(data)`
420
469
 
421
470
  Send response with automatic content-type detection.
@@ -429,12 +478,59 @@ ctx.send(null) // 204 No Content
429
478
 
430
479
  **Supported types:** Object, String, Buffer, null, undefined
431
480
 
481
+ ##### `ctx.sendJson(data, [status])`
482
+
483
+ Send a JSON response with explicit status code. Defaults to `200`.
484
+
485
+ ```javascript
486
+ ctx.sendJson({ users: [] })
487
+ ctx.sendJson({ error: 'Not found' }, 404)
488
+ ```
489
+
490
+ ##### `ctx.sendText(text, [status])`
491
+
492
+ Send a plain text response with explicit status code. Defaults to `200`.
493
+
494
+ ```javascript
495
+ ctx.sendText('OK')
496
+ ctx.sendText('Created', 201)
497
+ ```
498
+
499
+ ##### `ctx.sendBuffer(buffer, [status])`
500
+
501
+ Send a binary response with explicit status code. Defaults to `200`.
502
+
503
+ ```javascript
504
+ ctx.sendBuffer(Buffer.from('data'))
505
+ ctx.sendBuffer(imageBuffer, 201)
506
+ ```
507
+
508
+ ##### `ctx.sendError(error)`
509
+
510
+ 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`.
511
+
512
+ ```javascript
513
+ ctx.sendError(new Error('Something broke'))
514
+
515
+ // With custom status
516
+ const err = new Error('Not found')
517
+ err.status = 404
518
+ ctx.sendError(err)
519
+ ```
520
+
432
521
  ##### `ctx.reply(status, headers, body)`
433
522
 
434
- Send response with full control over status, headers, and body.
523
+ 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
524
 
436
525
  ```javascript
437
- ctx.reply(200, { 'content-type': 'application/json' }, '{"ok":true}')
526
+ ctx.reply(
527
+ 200,
528
+ {
529
+ 'content-type': 'application/json',
530
+ 'set-cookie': ['a=1; Path=/', 'b=2; Path=/refresh']
531
+ },
532
+ '{"ok":true}'
533
+ )
438
534
  ```
439
535
 
440
536
  ##### `ctx.stream(readable, [status], [headers])`
@@ -452,10 +548,13 @@ await ctx.stream(stream, 200, { 'content-type': 'video/mp4' })
452
548
 
453
549
  ##### `ctx.startStreaming([status], [headers])`
454
550
 
455
- Start streaming response manually (for advanced use cases).
551
+ Start streaming response manually (for advanced use cases). Header values may be strings or arrays of strings.
456
552
 
457
553
  ```javascript
458
- ctx.startStreaming(200, { 'content-type': 'text/plain' })
554
+ ctx.startStreaming(200, {
555
+ 'content-type': 'text/plain',
556
+ 'set-cookie': ['a=1; Path=/', 'b=2; Path=/refresh']
557
+ })
459
558
  ```
460
559
 
461
560
  ##### `ctx.write(chunk)`
@@ -824,6 +923,34 @@ process.on('SIGINT', () => {
824
923
  })
825
924
  ```
826
925
 
926
+ ### Route preHandlers
927
+
928
+ A route may declare a `preHandler` — one function or an array — run before its
929
+ `handler` (auth, logging, validation).
930
+
931
+ ```javascript
932
+ const requireAuth = (ctx) => {
933
+ if (ctx.header('authorization') !== 'Bearer secret') {
934
+ ctx.status(401).send('Unauthorized') // replying short-circuits the chain
935
+ }
936
+ }
937
+
938
+ const server = new Server({
939
+ routes: [
940
+ {
941
+ method: 'get',
942
+ path: '/admin',
943
+ preHandler: requireAuth,
944
+ handler: () => ({ ok: true })
945
+ }
946
+ ]
947
+ })
948
+ ```
949
+
950
+ - Run in order, sync or async (awaited); replying (`ctx.replied`) stops the chain.
951
+ - Composed once at registration — zero per-request cost for routes without one.
952
+ - Native `routes` API only (not the `router` function).
953
+
827
954
  ### Custom Response Headers
828
955
 
829
956
  ```javascript
@@ -844,6 +971,70 @@ const server = new Server({
844
971
  })
845
972
  ```
846
973
 
974
+ ### CORS
975
+
976
+ `cors(options)` stages CORS headers and replies to preflight (`OPTIONS`) requests.
977
+ Call it at the top of a handler; it returns `true` when it handled the preflight.
978
+
979
+ ```javascript
980
+ import Server, { cors } from '@swarmmachina/swm-core'
981
+
982
+ const applyCors = cors({
983
+ origin: 'https://app.example', // default '*'
984
+ credentials: true, // default false
985
+ maxAge: 600 // optional, preflight cache seconds
986
+ })
987
+
988
+ const server = new Server({
989
+ routes: [
990
+ {
991
+ method: 'any',
992
+ path: '/*',
993
+ handler: (ctx) => {
994
+ if (applyCors(ctx)) {
995
+ return // preflight handled (204)
996
+ }
997
+
998
+ return { ok: true }
999
+ }
1000
+ }
1001
+ ]
1002
+ })
1003
+ ```
1004
+
1005
+ Options: `origin` (default `'*'`), `methods`, `allowedHeaders`, `credentials`,
1006
+ `maxAge`. A non-`'*'` `origin` appends `Vary: Origin`; `credentials` requires an
1007
+ explicit `origin`.
1008
+
1009
+ ### Serving Static Files
1010
+
1011
+ `serveStatic(root, options)` returns a handler for a wildcard `/*` route (specific
1012
+ routes still take precedence). It guards against path traversal, sets Content-Type
1013
+ by extension, and caches file contents in memory.
1014
+
1015
+ ```javascript
1016
+ import Server, { serveStatic } from '@swarmmachina/swm-core'
1017
+
1018
+ const server = new Server({
1019
+ routes: [
1020
+ { method: 'get', path: '/api/health', handler: () => ({ ok: true }) },
1021
+ {
1022
+ method: 'get',
1023
+ path: '/*',
1024
+ handler: serveStatic('./public', {
1025
+ spa: true, // fall back to index.html for unmatched paths (default false)
1026
+ maxAge: 3600 // optional Cache-Control: public, max-age=<seconds>
1027
+ })
1028
+ }
1029
+ ]
1030
+ })
1031
+ ```
1032
+
1033
+ Options: `spa` (fall back to `index`), `index` (default `'index.html'`), `cache`
1034
+ (default `true`; set `false` in dev to pick up edits), `cacheLimit` (max cached
1035
+ files, default `128`), `maxAge` (`Cache-Control` seconds). Misses return `404`,
1036
+ traversal `403`, non-`GET`/`HEAD` `405`.
1037
+
847
1038
  ### Backpressure Handling
848
1039
 
849
1040
  ```javascript
@@ -881,6 +1072,62 @@ npm test
881
1072
  npm test:coverage
882
1073
  ```
883
1074
 
1075
+ ## Regression profiling (CI)
1076
+
1077
+ `npm run profile:ci` runs the regression-profiling suites (HTTP, body-parser and
1078
+ WebSocket), records CPU profiles and memory peaks, and fails on a guard breach.
1079
+ In CI it runs only on push to `master` and `workflow_dispatch` (see
1080
+ `.github/workflows/ci.yml`) and uploads the `regression-profiles` artifact.
1081
+ Thresholds live in `benchmark/baselines/*.json`.
1082
+
1083
+ GitHub's shared runners are noisy — throughput can swing 30–40% between runs — so
1084
+ absolute thresholds there only catch large regressions. Running the profiling job
1085
+ on a quiet self-hosted runner removes that noise and lets the thresholds be
1086
+ tightened enough to catch small regressions.
1087
+
1088
+ ### Self-hosted runner
1089
+
1090
+ Only the gated `regression-profile` job uses the self-hosted runner; the `test`
1091
+ job stays on `ubuntu-latest`.
1092
+
1093
+ > **Public-repository note.** The `regression-profile` job is gated to
1094
+ > `push`/`workflow_dispatch`, so fork pull requests never reach the self-hosted
1095
+ > machine — they only run `test` on `ubuntu-latest`. Anything merged to `master`
1096
+ > still executes on the runner, so treat the host as exposed: dedicated
1097
+ > unprivileged user, firewalled, no production secrets, ephemeral runner.
1098
+
1099
+ **1. Register the runner** (repo Settings → Actions → Runners → New self-hosted
1100
+ runner). The connection is outbound-only — no inbound ports, works behind NAT:
1101
+
1102
+ ```bash
1103
+ ./config.sh --url https://github.com/<owner>/<repo> --token <RUNNER_TOKEN> --labels bench --ephemeral
1104
+ sudo ./svc.sh install <user>
1105
+ sudo ./svc.sh start
1106
+ ```
1107
+
1108
+ **2. Harden the host.**
1109
+
1110
+ - Run the runner as a dedicated unprivileged user, never root.
1111
+ - Keep the host firewalled; allow only outbound HTTPS to GitHub.
1112
+ - Do not expose repository or production secrets to the runner.
1113
+ - `--ephemeral` de-registers the runner after each job, limiting persistence.
1114
+
1115
+ **3. Tune for low noise** (otherwise the variance returns):
1116
+
1117
+ ```bash
1118
+ sudo cpupower frequency-set -g performance
1119
+ ```
1120
+
1121
+ Keep the host idle during a run; optionally pin the runner to dedicated cores.
1122
+
1123
+ **4. Point the job at the runner.** In `.github/workflows/ci.yml`, set the
1124
+ `regression-profile` job to `runs-on: [self-hosted, Linux, bench]`.
1125
+
1126
+ **5. Recalibrate.** Throughput and memory numbers differ per machine, so recalibrate
1127
+ `benchmark/baselines/*.json` on the self-hosted runner: collect a few green runs,
1128
+ then set each guard just past the observed noise floor (throughput `min` ≈ observed
1129
+ low × 0.9; latency and memory `max` ≈ observed high × 1.15).
1130
+
884
1131
  ## Contributing
885
1132
 
886
1133
  Contributions are welcome! Please feel free to submit a Pull Request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmmachina/swm-core",
3
- "version": "1.1.5",
3
+ "version": "1.3.0",
4
4
  "description": "Zero-dependency high-performance HTTP/WebSocket server built on uWebSockets.js",
5
5
  "keywords": [
6
6
  "http",
@@ -15,12 +15,16 @@
15
15
  "author": "SwarmMachina Team",
16
16
  "license": "MPL-2.0",
17
17
  "main": "src/index.js",
18
+ "types": "./src/index.d.ts",
18
19
  "type": "module",
19
20
  "engines": {
20
21
  "node": ">=22.0.0"
21
22
  },
22
23
  "exports": {
23
- ".": "./src/index.js"
24
+ ".": {
25
+ "types": "./src/index.d.ts",
26
+ "default": "./src/index.js"
27
+ }
24
28
  },
25
29
  "files": [
26
30
  "src/",
@@ -44,18 +48,24 @@
44
48
  "test:e2e:ws": "node --test tests/e2e/ws/*.test.js",
45
49
  "bench": "node ./benchmark/bench.js --test base --runs 1 --warmup 10 --sample-ms 250 --v8prof true",
46
50
  "bench:core": "node ./benchmark/bench.js --test base --runs 1 --warmup 10 --sample-ms 250 --v8prof true --fw core",
51
+ "bench:headers": "node ./benchmark/bench.js --test headers --runs 1 --warmup 10 --sample-ms 250 --v8prof true",
52
+ "bench:headers:core": "node ./benchmark/bench.js --test headers --runs 1 --warmup 10 --sample-ms 250 --v8prof true --fw core",
53
+ "bench:ws": "node ./benchmark/ws-bench.js --fw core,ws --runs 1 --warmup 2 --v8prof true",
54
+ "bench:prehandler": "node ./benchmark/prehandler-bench.js",
55
+ "profile:ci": "node ./benchmark/regression-ci.js",
56
+ "profile:compare": "node ./benchmark/compare-ci.js",
47
57
  "prepublishOnly": "npm run fix && npm test",
48
58
  "release": "npm run check && npm test && npm publish"
49
59
  },
50
60
  "dependencies": {
51
- "uwebsockets.js": "uNetworking/uWebSockets.js#v20.56.0"
61
+ "uwebsockets.js": "uNetworking/uWebSockets.js#v20.67.0"
52
62
  },
53
63
  "devDependencies": {
54
- "@swarmmachina/standards": "^1.0.1",
64
+ "@swarmmachina/standards": "^1.0.3",
55
65
  "autocannon": "^8.0.0",
56
66
  "express": "^5.2.1",
57
- "fastify": "^5.6.2",
67
+ "fastify": "^5.8.5",
58
68
  "micro": "^10.0.1",
59
- "ws": "^8.18.3"
69
+ "ws": "^8.21.0"
60
70
  }
61
71
  }
package/src/cors.js ADDED
@@ -0,0 +1,53 @@
1
+ const DEFAULT_METHODS = 'GET,HEAD,PUT,PATCH,POST,DELETE'
2
+ const DEFAULT_HEADERS = 'Content-Type, Authorization'
3
+
4
+ /**
5
+ * @typedef {object} CorsOptions
6
+ * @property {string} [origin]
7
+ * @property {string} [methods]
8
+ * @property {string} [allowedHeaders]
9
+ * @property {boolean} [credentials]
10
+ * @property {number} [maxAge]
11
+ */
12
+
13
+ /**
14
+ * @param {CorsOptions} [options]
15
+ * @returns {(ctx: import('./http-context.js').default) => boolean}
16
+ */
17
+ export default function cors(options = {}) {
18
+ const origin = options.origin ?? '*'
19
+ const methods = options.methods ?? DEFAULT_METHODS
20
+ const allowedHeaders = options.allowedHeaders ?? DEFAULT_HEADERS
21
+ const credentials = options.credentials === true
22
+ const maxAge = options.maxAge
23
+
24
+ if (credentials && origin === '*') {
25
+ throw new TypeError("cors: 'credentials' requires an explicit 'origin' (wildcard '*' is rejected by browsers)")
26
+ }
27
+
28
+ return function applyCors(ctx) {
29
+ ctx.setHeader('access-control-allow-origin', origin)
30
+
31
+ if (origin !== '*') {
32
+ ctx.appendHeader('vary', 'Origin')
33
+ }
34
+
35
+ if (credentials) {
36
+ ctx.setHeader('access-control-allow-credentials', 'true')
37
+ }
38
+
39
+ if (ctx.method() !== 'options') {
40
+ return false
41
+ }
42
+
43
+ ctx.setHeader('access-control-allow-methods', methods)
44
+ ctx.setHeader('access-control-allow-headers', allowedHeaders)
45
+
46
+ if (maxAge != null) {
47
+ ctx.setHeader('access-control-max-age', `${maxAge}`)
48
+ }
49
+
50
+ ctx.reply(204, null, null)
51
+ return true
52
+ }
53
+ }
@@ -36,12 +36,14 @@ export default class HttpContext {
36
36
  }
37
37
 
38
38
  onResolve = (result) => {
39
- if (this.done || this.aborted || this.replied) {
39
+ if (this.done || this.aborted) {
40
40
  return
41
41
  }
42
42
 
43
43
  try {
44
- this.send(result)
44
+ if (!this.replied) {
45
+ this.send(result)
46
+ }
45
47
  } catch (err) {
46
48
  if (!this.replied) {
47
49
  try {
@@ -60,14 +62,16 @@ export default class HttpContext {
60
62
  }
61
63
 
62
64
  onReject = (err) => {
63
- if (this.done || this.aborted || this.replied) {
65
+ if (this.done || this.aborted) {
64
66
  return
65
67
  }
66
68
 
67
- try {
68
- this.sendError(err)
69
- } catch {
70
- //
69
+ if (!this.replied) {
70
+ try {
71
+ this.sendError(err)
72
+ } catch {
73
+ //
74
+ }
71
75
  }
72
76
 
73
77
  void this.server.safeHttpError(this, err)
@@ -428,12 +432,61 @@ export default class HttpContext {
428
432
  return this
429
433
  }
430
434
 
431
- this.#stageHeader(key, value)
435
+ if (typeof key !== 'string') {
436
+ throw new TypeError('Header name must be a string')
437
+ }
438
+
439
+ if (value === undefined || value === null) {
440
+ return this
441
+ }
442
+
443
+ const headerKey = key.toLowerCase()
444
+
445
+ this.#pendingHeaders.set(headerKey, [key, `${value}`])
432
446
  return this
433
447
  }
434
448
 
435
449
  /**
436
- * @param {Record<string, string> | null | undefined} headers
450
+ * @param {string} key
451
+ * @param {string} value
452
+ * @returns {HttpContext}
453
+ */
454
+ appendHeader(key, value) {
455
+ if (this.replied || this.aborted) {
456
+ return this
457
+ }
458
+
459
+ if (typeof key !== 'string') {
460
+ throw new TypeError('Header name must be a string')
461
+ }
462
+
463
+ if (value === undefined || value === null) {
464
+ return this
465
+ }
466
+
467
+ const headerKey = key.toLowerCase()
468
+ const headerValue = `${value}`
469
+ const pendingHeader = this.#pendingHeaders.get(headerKey)
470
+
471
+ if (!pendingHeader) {
472
+ this.#pendingHeaders.set(headerKey, [key, headerValue])
473
+ return this
474
+ }
475
+
476
+ pendingHeader[0] = key
477
+ const cur = pendingHeader[1]
478
+
479
+ if (typeof cur === 'string') {
480
+ pendingHeader[1] = [cur, headerValue]
481
+ } else {
482
+ cur[cur.length] = headerValue
483
+ }
484
+
485
+ return this
486
+ }
487
+
488
+ /**
489
+ * @param {Record<string, string | string[]> | null | undefined} headers
437
490
  */
438
491
  setHeaders(headers) {
439
492
  if (this.replied || this.aborted) {
@@ -444,7 +497,7 @@ export default class HttpContext {
444
497
  }
445
498
 
446
499
  /**
447
- * @param {Record<string, string> | null | undefined} headers
500
+ * @param {Record<string, string | string[]> | null | undefined} headers
448
501
  */
449
502
  flushHeaders(headers = null) {
450
503
  this.#flushPendingHeaders(headers)
@@ -452,21 +505,83 @@ export default class HttpContext {
452
505
 
453
506
  /**
454
507
  * @param {string} key
455
- * @param {string} value
508
+ * @param {string | string[] | null | undefined} value
509
+ * @param {boolean} append
456
510
  */
457
- #stageHeader(key, value) {
511
+ #stagePendingHeader(key, value, append) {
458
512
  if (value === undefined || value === null) {
459
513
  return
460
514
  }
461
515
 
462
- const headerName = String(key)
463
- const headerValue = String(value)
516
+ const headerKey = key.toLowerCase()
464
517
 
465
- this.#pendingHeaders.set(headerName.toLowerCase(), [headerName, headerValue])
518
+ if (!Array.isArray(value)) {
519
+ const headerValue = `${value}`
520
+
521
+ if (!append) {
522
+ this.#pendingHeaders.set(headerKey, [key, headerValue])
523
+ return
524
+ }
525
+
526
+ const pendingHeader = this.#pendingHeaders.get(headerKey)
527
+
528
+ if (!pendingHeader) {
529
+ this.#pendingHeaders.set(headerKey, [key, headerValue])
530
+ return
531
+ }
532
+
533
+ pendingHeader[0] = key
534
+ const cur = pendingHeader[1]
535
+
536
+ if (typeof cur === 'string') {
537
+ pendingHeader[1] = [cur, headerValue]
538
+ } else {
539
+ cur[cur.length] = headerValue
540
+ }
541
+
542
+ return
543
+ }
544
+
545
+ this.#stagePendingHeaderArray(key, headerKey, value, append)
466
546
  }
467
547
 
468
548
  /**
469
- * @param {Record<string, string> | null | undefined} headers
549
+ * @param {string} key
550
+ * @param {string} headerKey
551
+ * @param {string[]} value
552
+ * @param {boolean} append
553
+ */
554
+ #stagePendingHeaderArray(key, headerKey, value, append) {
555
+ let pendingHeader = append ? this.#pendingHeaders.get(headerKey) : null
556
+
557
+ for (let i = 0, len = value.length; i < len; i++) {
558
+ const entry = value[i]
559
+
560
+ if (entry === undefined || entry === null) {
561
+ continue
562
+ }
563
+
564
+ const headerValue = `${entry}`
565
+
566
+ if (!pendingHeader) {
567
+ pendingHeader = [key, headerValue]
568
+ this.#pendingHeaders.set(headerKey, pendingHeader)
569
+ continue
570
+ }
571
+
572
+ pendingHeader[0] = key
573
+ const cur = pendingHeader[1]
574
+
575
+ if (typeof cur === 'string') {
576
+ pendingHeader[1] = [cur, headerValue]
577
+ } else {
578
+ cur[cur.length] = headerValue
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * @param {Record<string, string | string[]> | null | undefined} headers
470
585
  */
471
586
  #stageHeaders(headers) {
472
587
  if (!headers) {
@@ -474,29 +589,27 @@ export default class HttpContext {
474
589
  }
475
590
 
476
591
  if (headers === TEXT_PLAIN_HEADER) {
477
- this.#stageHeader('content-type', 'text/plain; charset=utf-8')
592
+ this.#pendingHeaders.set('content-type', ['content-type', 'text/plain; charset=utf-8'])
478
593
  return
479
594
  }
480
595
 
481
596
  if (headers === JSON_HEADER) {
482
- this.#stageHeader('content-type', 'application/json; charset=utf-8')
597
+ this.#pendingHeaders.set('content-type', ['content-type', 'application/json; charset=utf-8'])
483
598
  return
484
599
  }
485
600
 
486
601
  if (headers === OCTET_STREAM_HEADER) {
487
- this.#stageHeader('content-type', 'application/octet-stream')
602
+ this.#pendingHeaders.set('content-type', ['content-type', 'application/octet-stream'])
488
603
  return
489
604
  }
490
605
 
491
606
  for (const key in headers) {
492
- const value = headers[key]
493
-
494
- this.#stageHeader(key, value)
607
+ this.#stagePendingHeader(key, headers[key], false)
495
608
  }
496
609
  }
497
610
 
498
611
  /**
499
- * @param {Record<string, string> | null | undefined} headers
612
+ * @param {Record<string, string | string[]> | null | undefined} headers
500
613
  */
501
614
  #flushPendingHeaders(headers = null) {
502
615
  if (!this.res) {
@@ -508,8 +621,19 @@ export default class HttpContext {
508
621
  this.#stageHeaders(headers)
509
622
  }
510
623
 
511
- for (const [, [key, value]] of this.#pendingHeaders) {
512
- this.res.writeHeader(key, value)
624
+ for (const [, pendingHeader] of this.#pendingHeaders) {
625
+ const headerValue = pendingHeader[1]
626
+
627
+ if (typeof headerValue === 'string') {
628
+ this.res.writeHeader(pendingHeader[0], headerValue)
629
+ continue
630
+ }
631
+
632
+ const name = pendingHeader[0]
633
+
634
+ for (let i = 0, len = headerValue.length; i < len; i++) {
635
+ this.res.writeHeader(name, headerValue[i])
636
+ }
513
637
  }
514
638
 
515
639
  this.#pendingHeaders.clear()
@@ -582,7 +706,7 @@ export default class HttpContext {
582
706
 
583
707
  /**
584
708
  * @param {number} status
585
- * @param {Record<string,string>} headers
709
+ * @param {Record<string, string | string[]>} headers
586
710
  * @param {string|ArrayBuffer|Uint8Array|Buffer|null|undefined} body
587
711
  */
588
712
  reply(status = 200, headers = null, body = null) {
@@ -610,7 +734,7 @@ export default class HttpContext {
610
734
 
611
735
  /**
612
736
  * @param {number} status
613
- * @param {Record<string,string>} headers
737
+ * @param {Record<string, string | string[]>} headers
614
738
  * @returns {HttpContext}
615
739
  */
616
740
  startStreaming(status = 200, headers = null) {
package/src/index.d.ts ADDED
@@ -0,0 +1,168 @@
1
+ import type { Readable } from 'node:stream'
2
+
3
+ export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'del' | 'patch' | 'options' | 'head' | 'any'
4
+
5
+ /** Body payload accepted by response/streaming writers. */
6
+ export type HttpBody = string | ArrayBuffer | ArrayBufferView | Buffer
7
+
8
+ /** Response headers map; values may be a single string or repeated as an array. */
9
+ export type HttpHeaders = Record<string, string | string[]>
10
+
11
+ /** HTTP route handler. Its return value is sent via `ctx.send()` unless the response was already written. */
12
+ export type Handler = (ctx: HttpContext) => any | Promise<any>
13
+
14
+ export interface Route {
15
+ method: HttpMethod
16
+ path: string
17
+ handler: Handler
18
+ /** One handler or a chain, run before `handler`. Replying short-circuits the chain. Native `routes` only. */
19
+ preHandler?: Handler | Handler[]
20
+ }
21
+
22
+ /** Metadata passed to `ws.onUpgrade`. */
23
+ export interface UpgradeMeta {
24
+ url(): string
25
+ ip(): string
26
+ getParameter(index: number): string
27
+ getQuery(key?: string): string
28
+ getHeader(name: string): string
29
+ aborted: boolean
30
+ }
31
+
32
+ export interface UpgradeResult {
33
+ isAllowed: boolean
34
+ userData?: object
35
+ }
36
+
37
+ export interface WSOptions {
38
+ enabled?: boolean
39
+ wsIdleTimeoutSec?: number
40
+ onOpen?: (ctx: WSContext) => any
41
+ onMessage?: (ctx: WSContext, message: ArrayBuffer, isBinary: boolean) => any
42
+ onClose?: (ctx: WSContext, code: number, message: ArrayBuffer) => any
43
+ onDrain?: (ctx: WSContext) => any
44
+ onError?: (ctx: WSContext | null, err: Error) => any
45
+ onUpgrade?: (meta: UpgradeMeta) => UpgradeResult | Promise<UpgradeResult>
46
+ onSubscription?: (ctx: WSContext, topic: ArrayBuffer, newCount: number, oldCount: number) => any
47
+ }
48
+
49
+ export interface ServerOptions {
50
+ /** Universal router function (micro-like API). Provide either `router` or `routes`, not both. */
51
+ router?: Handler
52
+ /** Native routing API: an array of route definitions. Provide either `router` or `routes`, not both. */
53
+ routes?: Route[]
54
+ onHttpError?: (ctx: HttpContext, err: Error) => any | Promise<any>
55
+ /** @default 6000 */
56
+ port?: number
57
+ /** Max request body size in MB (1-64). @default 1 */
58
+ maxBodySize?: number
59
+ ws?: WSOptions
60
+ }
61
+
62
+ /** Per-request context passed to HTTP handlers. Instances are pooled and reused. */
63
+ export class HttpContext {
64
+ /** Whether a response has already been sent. */
65
+ replied: boolean
66
+ /** Whether the underlying request was aborted by the client. */
67
+ aborted: boolean
68
+
69
+ body(maxSize?: number): Promise<Buffer>
70
+ buffer(maxSize?: number): Promise<Buffer>
71
+ text(maxSize?: number): Promise<string>
72
+ json<T = any>(maxSize?: number): Promise<T>
73
+
74
+ ip(): string
75
+ method(): string
76
+ url(): string
77
+ fullQuery(): string
78
+ query(name: string): string | undefined
79
+ param(indexOrName: number | string): string | undefined
80
+ header(name: string): string
81
+ contentLength(): number | null
82
+
83
+ status(code: number): this
84
+ setHeader(key: string, value: string | number): this
85
+ appendHeader(key: string, value: string | number): this
86
+ setHeaders(headers: HttpHeaders | null | undefined): void
87
+ flushHeaders(headers?: HttpHeaders | null): void
88
+
89
+ send(data: any): void
90
+ sendJson(data: any, status?: number): void
91
+ sendText(text: string, status?: number): void
92
+ sendBuffer(buffer: Buffer | Uint8Array | ArrayBuffer, status?: number): void
93
+ sendError(error: { status?: number; message?: string } | Error): void
94
+ reply(status?: number, headers?: HttpHeaders | null, body?: HttpBody | null): void
95
+
96
+ stream(readable: Readable, status?: number, headers?: HttpHeaders | null): Promise<void>
97
+ startStreaming(status?: number, headers?: HttpHeaders | null): this
98
+ write(chunk: HttpBody): boolean
99
+ end(chunk?: HttpBody): void
100
+ onWritable(callback: (offset: number) => void): void
101
+ tryEnd(chunk: HttpBody, totalSize?: number): [boolean, boolean]
102
+ getWriteOffset(): number
103
+ }
104
+
105
+ /** Per-connection context passed to WebSocket handlers. Instances are pooled and reused. */
106
+ export class WSContext {
107
+ /** User data returned from `ws.onUpgrade` (`userData` field). */
108
+ data: any
109
+ /** Raw uWebSockets.js WebSocket object. */
110
+ ws: any
111
+
112
+ send(data: string | ArrayBuffer | ArrayBufferView, isBinary?: boolean): number
113
+ end(code?: number, reason?: string): void
114
+ subscribe(topic: string): boolean
115
+ unsubscribe(topic: string): boolean
116
+ publish(topic: string, message: string | ArrayBuffer | ArrayBufferView, isBinary?: boolean): boolean
117
+ }
118
+
119
+ export default class Server {
120
+ constructor(options: ServerOptions)
121
+
122
+ readonly port: number
123
+
124
+ /** Start the server and begin accepting connections. */
125
+ listen(): Promise<this>
126
+ /** Gracefully shut down, waiting up to `timeout` ms for active connections to finish. @default 10000 */
127
+ shutdown(timeout?: number): Promise<void>
128
+ /** Forcefully close the server immediately. */
129
+ close(): void
130
+ /** Publish a message to all clients subscribed to `topic`. */
131
+ publish(topic: string, message: string | ArrayBuffer | Uint8Array | Buffer, isBinary?: boolean): boolean
132
+ /** Number of subscribers for a topic. */
133
+ getSubscribersCount(topic: string): number
134
+ }
135
+
136
+ export interface CorsOptions {
137
+ /** @default '*' */
138
+ origin?: string
139
+ methods?: string
140
+ allowedHeaders?: string
141
+ /** @default false */
142
+ credentials?: boolean
143
+ /** Preflight cache lifetime in seconds. */
144
+ maxAge?: number
145
+ }
146
+
147
+ /**
148
+ * Build a CORS applier. Call it at the top of a handler; it returns `true` when it
149
+ * already replied to a preflight (`OPTIONS`) request. Throws if `credentials` is set
150
+ * together with the wildcard `origin` `'*'`.
151
+ */
152
+ export function cors(options?: CorsOptions): (ctx: HttpContext) => boolean
153
+
154
+ export interface ServeStaticOptions {
155
+ /** Fall back to the index file for unmatched paths. @default false */
156
+ spa?: boolean
157
+ /** @default 'index.html' */
158
+ index?: string
159
+ /** In-memory content cache. @default true */
160
+ cache?: boolean
161
+ /** Max number of cached files (FIFO eviction). @default 128 */
162
+ cacheLimit?: number
163
+ /** `Cache-Control: public, max-age=<seconds>`. */
164
+ maxAge?: number
165
+ }
166
+
167
+ /** Build a handler that serves files from `root`, intended for a wildcard `/*` route. */
168
+ export function serveStatic(root: string, options?: ServeStaticOptions): (ctx: HttpContext) => Promise<void>
package/src/index.js CHANGED
@@ -5,6 +5,9 @@ import WSContext from './ws-context.js'
5
5
  import ContextPool from './context-pool.js'
6
6
  import { STATUS_TEXT } from './constants.js'
7
7
 
8
+ export { default as cors } from './cors.js'
9
+ export { default as serveStatic } from './serve-static.js'
10
+
8
11
  const WS_CONTEXT_SYMBOL = Symbol('WS_CONTEXT')
9
12
 
10
13
  const isPromise = (v) => v != null && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function'
@@ -27,6 +30,7 @@ const isPromise = (v) => v != null && (typeof v === 'object' || typeof v === 'fu
27
30
  * @property {'get'|'post'|'put'|'delete'|'del'|'patch'|'options'|'head'|'any'} method
28
31
  * @property {string} path - '/users/:id','/*'
29
32
  * @property {(ctx: HttpContext) => any|Promise<any>} handler
33
+ * @property {((ctx: HttpContext) => any|Promise<any>)|((ctx: HttpContext) => any|Promise<any>)[]} [preHandler]
30
34
  */
31
35
 
32
36
  export default class Server {
@@ -138,7 +142,7 @@ export default class Server {
138
142
 
139
143
  if (this.useNativeRouting) {
140
144
  for (const route of this.routes) {
141
- const { method, path, handler } = route
145
+ const { method, path, handler, preHandler } = route
142
146
  const methodName = method === 'delete' ? 'del' : method
143
147
 
144
148
  if (typeof this.app[methodName] !== 'function') {
@@ -149,7 +153,9 @@ export default class Server {
149
153
  throw new TypeError(`Invalid Path in route, method: ${method}, path: ${path}`)
150
154
  }
151
155
 
152
- this.app[methodName](path, (res, req) => this.handleWithContext(res, req, handler))
156
+ const routeHandler = this.#composeRouteHandler(handler, preHandler)
157
+
158
+ this.app[methodName](path, (res, req) => this.handleWithContext(res, req, routeHandler))
153
159
  }
154
160
  } else {
155
161
  this.app.any('/*', (res, req) => this.handleWithContext(res, req, this.router))
@@ -186,6 +192,42 @@ export default class Server {
186
192
  return this.#listenPromise
187
193
  }
188
194
 
195
+ /**
196
+ * @param {(ctx: HttpContext) => any|Promise<any>} handler
197
+ * @param {((ctx: HttpContext) => any|Promise<any>)|((ctx: HttpContext) => any|Promise<any>)[]} [preHandler]
198
+ * @returns {(ctx: HttpContext) => any|Promise<any>}
199
+ */
200
+ #composeRouteHandler(handler, preHandler) {
201
+ if (preHandler == null) {
202
+ return handler
203
+ }
204
+
205
+ const chain = Array.isArray(preHandler) ? preHandler : [preHandler]
206
+
207
+ for (let i = 0; i < chain.length; i++) {
208
+ if (typeof chain[i] !== 'function') {
209
+ throw new TypeError('Route preHandler must be a function or an array of functions')
210
+ }
211
+ }
212
+
213
+ if (chain.length === 0) {
214
+ return handler
215
+ }
216
+
217
+ return async (ctx) => {
218
+ for (let i = 0; i < chain.length; i++) {
219
+ await chain[i](ctx)
220
+
221
+ if (ctx.replied || ctx.aborted) {
222
+ ctx.finalize()
223
+ return
224
+ }
225
+ }
226
+
227
+ return handler(ctx)
228
+ }
229
+ }
230
+
189
231
  /**
190
232
  * @param {Function} fn
191
233
  * @param {...any} args
@@ -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) {
@@ -0,0 +1,143 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { resolve, join, extname, sep } from 'node:path'
3
+
4
+ const MIME_TYPES = {
5
+ '.html': 'text/html; charset=utf-8',
6
+ '.js': 'text/javascript; charset=utf-8',
7
+ '.mjs': 'text/javascript; charset=utf-8',
8
+ '.css': 'text/css; charset=utf-8',
9
+ '.json': 'application/json; charset=utf-8',
10
+ '.svg': 'image/svg+xml',
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg',
13
+ '.jpeg': 'image/jpeg',
14
+ '.gif': 'image/gif',
15
+ '.webp': 'image/webp',
16
+ '.ico': 'image/x-icon',
17
+ '.txt': 'text/plain; charset=utf-8',
18
+ '.map': 'application/json; charset=utf-8',
19
+ '.wasm': 'application/wasm',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ '.ttf': 'font/ttf'
23
+ }
24
+
25
+ const OCTET_STREAM = 'application/octet-stream'
26
+
27
+ /**
28
+ * @param {string} filePath
29
+ * @returns {string}
30
+ */
31
+ function mimeFor(filePath) {
32
+ return MIME_TYPES[extname(filePath).toLowerCase()] || OCTET_STREAM
33
+ }
34
+
35
+ /**
36
+ * @typedef {object} ServeStaticOptions
37
+ * @property {boolean} [spa]
38
+ * @property {string} [index]
39
+ * @property {boolean} [cache]
40
+ * @property {number} [cacheLimit]
41
+ * @property {number} [maxAge]
42
+ */
43
+
44
+ /**
45
+ * @param {string} root
46
+ * @param {ServeStaticOptions} [options]
47
+ * @returns {(ctx: import('./http-context.js').default) => Promise<void>}
48
+ */
49
+ export default function serveStatic(root, options = {}) {
50
+ const rootDir = resolve(root)
51
+ const indexFile = options.index ?? 'index.html'
52
+ const spa = options.spa === true
53
+ const useCache = options.cache !== false
54
+ const cacheLimit = options.cacheLimit ?? 128
55
+ const maxAge = options.maxAge
56
+ const cacheControl = maxAge != null ? `public, max-age=${maxAge}` : null
57
+ const cache = useCache ? new Map() : null
58
+
59
+ /**
60
+ * @param {string} absPath
61
+ * @returns {Promise<{ buf: Buffer, type: string } | null>}
62
+ */
63
+ async function load(absPath) {
64
+ if (cache && cache.has(absPath)) {
65
+ return cache.get(absPath)
66
+ }
67
+
68
+ let buf
69
+
70
+ try {
71
+ const info = await stat(absPath)
72
+
73
+ if (!info.isFile()) {
74
+ return null
75
+ }
76
+
77
+ buf = await readFile(absPath)
78
+ } catch {
79
+ return null
80
+ }
81
+
82
+ const entry = { buf, type: mimeFor(absPath) }
83
+
84
+ if (cache) {
85
+ if (cache.size >= cacheLimit) {
86
+ cache.delete(cache.keys().next().value)
87
+ }
88
+
89
+ cache.set(absPath, entry)
90
+ }
91
+
92
+ return entry
93
+ }
94
+
95
+ return async function handleStatic(ctx) {
96
+ const method = ctx.method()
97
+
98
+ if (method !== 'get' && method !== 'head') {
99
+ ctx.status(405).send('Method Not Allowed')
100
+ return
101
+ }
102
+
103
+ let pathname
104
+
105
+ try {
106
+ pathname = decodeURIComponent(ctx.url())
107
+ } catch {
108
+ ctx.status(400).send('Bad Request')
109
+ return
110
+ }
111
+
112
+ if (pathname.endsWith('/')) {
113
+ pathname += indexFile
114
+ }
115
+
116
+ const rel = pathname.replace(/^\/+/, '')
117
+ const absPath = resolve(rootDir, rel)
118
+
119
+ if (absPath !== rootDir && !absPath.startsWith(rootDir + sep)) {
120
+ ctx.status(403).send('Forbidden')
121
+ return
122
+ }
123
+
124
+ let entry = await load(absPath)
125
+
126
+ if (!entry && spa) {
127
+ entry = await load(join(rootDir, indexFile))
128
+ }
129
+
130
+ if (!entry) {
131
+ ctx.status(404).send('Not Found')
132
+ return
133
+ }
134
+
135
+ if (cacheControl) {
136
+ ctx.setHeader('cache-control', cacheControl)
137
+ }
138
+
139
+ // uWS strips the body for HEAD requests at the native level and keeps the
140
+ // correct Content-Length, so GET and HEAD share the same reply path.
141
+ ctx.reply(200, { 'content-type': entry.type }, entry.buf)
142
+ }
143
+ }