@swarmmachina/swm-core 1.2.0 → 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
 
@@ -905,6 +923,34 @@ process.on('SIGINT', () => {
905
923
  })
906
924
  ```
907
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
+
908
954
  ### Custom Response Headers
909
955
 
910
956
  ```javascript
@@ -925,6 +971,70 @@ const server = new Server({
925
971
  })
926
972
  ```
927
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
+
928
1038
  ### Backpressure Handling
929
1039
 
930
1040
  ```javascript
@@ -962,6 +1072,62 @@ npm test
962
1072
  npm test:coverage
963
1073
  ```
964
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
+
965
1131
  ## Contributing
966
1132
 
967
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.2.0",
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/",
@@ -46,18 +50,22 @@
46
50
  "bench:core": "node ./benchmark/bench.js --test base --runs 1 --warmup 10 --sample-ms 250 --v8prof true --fw core",
47
51
  "bench:headers": "node ./benchmark/bench.js --test headers --runs 1 --warmup 10 --sample-ms 250 --v8prof true",
48
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",
49
57
  "prepublishOnly": "npm run fix && npm test",
50
58
  "release": "npm run check && npm test && npm publish"
51
59
  },
52
60
  "dependencies": {
53
- "uwebsockets.js": "uNetworking/uWebSockets.js#v20.56.0"
61
+ "uwebsockets.js": "uNetworking/uWebSockets.js#v20.67.0"
54
62
  },
55
63
  "devDependencies": {
56
- "@swarmmachina/standards": "^1.0.1",
64
+ "@swarmmachina/standards": "^1.0.3",
57
65
  "autocannon": "^8.0.0",
58
66
  "express": "^5.2.1",
59
- "fastify": "^5.6.2",
67
+ "fastify": "^5.8.5",
60
68
  "micro": "^10.0.1",
61
- "ws": "^8.18.3"
69
+ "ws": "^8.21.0"
62
70
  }
63
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)
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
@@ -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
+ }