@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 +171 -5
- package/package.json +14 -6
- package/src/cors.js +53 -0
- package/src/http-context.js +11 -7
- package/src/index.d.ts +168 -0
- package/src/index.js +44 -2
- package/src/serve-static.js +143 -0
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
|
|
212
|
-
|
|
|
213
|
-
| `method`
|
|
214
|
-
| `path`
|
|
215
|
-
| `handler`
|
|
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.
|
|
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
|
-
".":
|
|
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.
|
|
61
|
+
"uwebsockets.js": "uNetworking/uWebSockets.js#v20.67.0"
|
|
54
62
|
},
|
|
55
63
|
"devDependencies": {
|
|
56
|
-
"@swarmmachina/standards": "^1.0.
|
|
64
|
+
"@swarmmachina/standards": "^1.0.3",
|
|
57
65
|
"autocannon": "^8.0.0",
|
|
58
66
|
"express": "^5.2.1",
|
|
59
|
-
"fastify": "^5.
|
|
67
|
+
"fastify": "^5.8.5",
|
|
60
68
|
"micro": "^10.0.1",
|
|
61
|
-
"ws": "^8.
|
|
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
|
+
}
|
package/src/http-context.js
CHANGED
|
@@ -36,12 +36,14 @@ export default class HttpContext {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
onResolve = (result) => {
|
|
39
|
-
if (this.done || this.aborted
|
|
39
|
+
if (this.done || this.aborted) {
|
|
40
40
|
return
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
|
-
this.
|
|
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
|
|
65
|
+
if (this.done || this.aborted) {
|
|
64
66
|
return
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
+
}
|