velocious 1.0.570 → 1.0.571
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 +44 -0
- package/build/configuration-types.js +17 -0
- package/build/configuration.js +70 -0
- package/build/http-server/client/index.js +77 -4
- package/build/http-server/client/response-compression.js +224 -0
- package/build/http-server/client/response.js +57 -0
- package/build/http-server/websocket-events-host.js +91 -48
- package/build/src/configuration-types.d.ts +55 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +16 -1
- package/build/src/configuration.d.ts +6 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +61 -1
- package/build/src/http-server/client/index.d.ts +17 -1
- package/build/src/http-server/client/index.d.ts.map +1 -1
- package/build/src/http-server/client/index.js +74 -5
- package/build/src/http-server/client/response-compression.d.ts +71 -0
- package/build/src/http-server/client/response-compression.d.ts.map +1 -0
- package/build/src/http-server/client/response-compression.js +197 -0
- package/build/src/http-server/client/response.d.ts +27 -0
- package/build/src/http-server/client/response.d.ts.map +1 -1
- package/build/src/http-server/client/response.js +49 -1
- package/build/src/http-server/websocket-events-host.d.ts +28 -11
- package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
- package/build/src/http-server/websocket-events-host.js +84 -46
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/scripts/docker-run.sh +20 -0
- package/scripts/verify-docker-dev-environment.js +579 -0
- package/src/configuration-types.js +17 -0
- package/src/configuration.js +70 -0
- package/src/http-server/client/index.js +77 -4
- package/src/http-server/client/response-compression.js +224 -0
- package/src/http-server/client/response.js +57 -0
- package/src/http-server/websocket-events-host.js +91 -48
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
|
|
27
27
|
* Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
|
|
28
28
|
* Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
|
|
29
|
+
* Default-on buffered HTTP response compression with Brotli/gzip content negotiation, global and per-response opt-outs, and HEAD-correct representation headers (see [docs/http-server.md](docs/http-server.md#response-compression))
|
|
29
30
|
* Background jobs with failure events for production reporting and authorized database-scoped dashboard count snapshots/deltas (see [docs/background-jobs.md](docs/background-jobs.md) and [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md))
|
|
30
31
|
* Durable one-off background-job scheduling with exact epoch timestamps (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
|
|
31
32
|
* Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
|
|
@@ -93,6 +94,45 @@ npm run test:expo
|
|
|
93
94
|
|
|
94
95
|
Maintainers cutting a package release must follow the [Velocious release runbook](docs/releasing.md); `npm run release:patch` commits, pushes, and publishes rather than acting as a local-only version command.
|
|
95
96
|
|
|
97
|
+
# Docker development environment
|
|
98
|
+
|
|
99
|
+
The checked-in root `Dockerfile` and `compose.yml` define one canonical `dev` service used by humans, CI, and agent systems alike (see [docs/docker-development-environment.md](docs/docker-development-environment.md)). The image is Ubuntu 26.04 LTS (pinned by digest) with Node.js 24.x from signed NodeSource, the universal apt coding/debugging baseline, and the newest published provider CLIs; it is source-independent — no project source is copied and no project dependencies are installed at image build time.
|
|
100
|
+
|
|
101
|
+
Prerequisites: Docker with the Compose v2 plugin, and this repository checked out at `$DEV_HOME_PATH/velocious` (default `DEV_HOME_PATH`: `/home/dev`).
|
|
102
|
+
|
|
103
|
+
First-use setup: copy `.env.example` to the git-ignored `.env` and set `GH_CONFIG_SOURCE_PATH` to an existing host GitHub CLI config directory:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
cp .env.example .env
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`$DEV_HOME_PATH` must be a dedicated development home that already exists, holds no credentials or secrets, and is owned by (or at least writable by) UID/GID 1000 — the in-container `dev` user. Do not point it at a general host home directory, and do not recursively chown an existing home; the external environment owns safe initial provisioning.
|
|
110
|
+
|
|
111
|
+
Normal usage:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
docker compose up --build --detach dev
|
|
115
|
+
docker compose exec dev bash
|
|
116
|
+
scripts/docker-run.sh npm ci # one-off command in a disposable container
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The dev service preserves the complete `$DEV_HOME_PATH` bind at `/home/dev`, so dependencies, caches, settings, and `node_modules` persist naturally across runs. Install dependencies with the normal package commands inside the service (for example `docker compose exec dev npm ci`), never at image build time.
|
|
120
|
+
|
|
121
|
+
Concurrent isolated instances use the standard Compose project-name contract plus a distinct development home per instance:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
COMPOSE_PROJECT_NAME=velocious-review DEV_HOME_PATH=/srv/dev-homes/review \
|
|
125
|
+
docker compose up --build --detach dev
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
GitHub CLI authentication is the sole authorized credential boundary: the host config directory named by `GH_CONFIG_SOURCE_PATH` is mounted read-only at `/home/dev/.config/gh`, with container-side `GH_CONFIG_DIR` pointing there. Do not add SSH keys or other credential mounts. Kimi (and other provider) credentials are intentionally kept out of the tracked Compose files — they are an external operational override layered on by the calling environment. Threadwire is not installed in the image or the project; it remains parent orchestration resolved through unversioned `npx` outside the container.
|
|
129
|
+
|
|
130
|
+
After changing the Docker artifacts, run the checked-in static contract verifier:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm run verify:docker-dev-environment
|
|
134
|
+
```
|
|
135
|
+
|
|
96
136
|
# Code quality (fallow)
|
|
97
137
|
|
|
98
138
|
[fallow](https://github.com/fallow-rs/fallow) analyzes the codebase for unused/dead code, duplication, and complexity. CI runs it as a **regression gate**: it fails only on findings beyond the committed baseline in `fallow-baselines/`, so existing backlog never blocks a PR but new issues do.
|
|
@@ -1831,6 +1871,8 @@ this.getConfiguration().getWebsocketEvents().publish(channel, payload)
|
|
|
1831
1871
|
this.renderJsonArg({status: "published"})
|
|
1832
1872
|
```
|
|
1833
1873
|
|
|
1874
|
+
Publishes are queued per channel: events on the same channel are persisted and dispatched in FIFO order, while a slow or failing channel never delays unrelated channels. `configuration.awaitPendingBroadcasts()` settles once every broadcast accepted before the call has settled. See [docs/websocket-channels.md](docs/websocket-channels.md#publish-ordering-and-failure-semantics) for the full ordering and failure contract.
|
|
1875
|
+
|
|
1834
1876
|
## Websocket channels
|
|
1835
1877
|
|
|
1836
1878
|
You can resolve websocket channel classes from subscribe messages and let them decide which streams to allow:
|
|
@@ -2365,6 +2407,8 @@ When the server runs in the `development` environment, Velocious watches applica
|
|
|
2365
2407
|
|
|
2366
2408
|
Starting the HTTP server creates `tmp/server.lock` under the configured application directory before Beacon, workers, or the TCP listener start. A second server for the same app fails fast with the lock owner details instead of partially starting. Normal shutdown removes the lock; stale locks with a dead local PID are reclaimed automatically, while locks from another host or unreadable metadata should be removed manually only after confirming no server is running. See [docs/http-server.md](docs/http-server.md#server-lock).
|
|
2367
2409
|
|
|
2410
|
+
Buffered string and `Uint8Array` responses are compressed with Brotli (`br`) or gzip by default whenever request negotiation and response eligibility allow — no opt-in is required. Disable compression globally with `httpServer.compression: false` or `httpServer.compression: {enabled: false}`, and tune it with `threshold`/`brotliQuality`/`gzipLevel` overrides. Negotiation honors `Accept-Encoding` q-values, wildcards, and identity semantics (empty `406` when no acceptable representation exists), merges `Accept-Encoding` into `Vary`, and skips streamed `sendFile` responses, already-encoded or `no-transform` responses, server-sent events, partial/range responses, bodyless statuses, and non-allowlisted content types. Transformation is additionally excluded automatically for credentialed traffic and validator-carrying responses — requests with `Authorization`/`Cookie` and responses with `Set-Cookie`, `ETag`, `Digest`, or `Content-Digest` are never compressed (compression-oracle protection, and validators stay application-owned). Controllers opt out per response with `response.disableCompression()`, and HEAD requests compute GET-equivalent representation headers without emitting a body. See [docs/http-server.md](docs/http-server.md#response-compression).
|
|
2411
|
+
|
|
2368
2412
|
# Authorization (CanCan-style)
|
|
2369
2413
|
|
|
2370
2414
|
Define resource classes with an `abilities()` method and use `can` / `cannot` rules to constrain model access.
|
|
@@ -249,8 +249,25 @@
|
|
|
249
249
|
* @property {number} [unreachableReportMs] - Grace window (ms) a beacon connect/disconnect blip must persist before it is reported as a framework-error. Transient outages that recover within this window (e.g. a deploy restarting the broker) are not reported. Defaults to 30000.
|
|
250
250
|
*/
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* @typedef {object} HttpCompressionConfiguration
|
|
254
|
+
* @property {boolean} [enabled] - Whether buffered response compression is enabled. Defaults to true; set false to disable globally.
|
|
255
|
+
* @property {number} [threshold] - Minimum buffered body size in bytes before compression is applied. Defaults to 1024.
|
|
256
|
+
* @property {number} [brotliQuality] - Brotli encoder quality (0-11). Defaults to 4.
|
|
257
|
+
* @property {number} [gzipLevel] - Gzip compression level (0-9). Defaults to 6.
|
|
258
|
+
*/
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @typedef {object} NormalizedHttpCompressionConfiguration
|
|
262
|
+
* @property {boolean} enabled - Whether buffered HTTP response compression is enabled.
|
|
263
|
+
* @property {number} threshold - Minimum buffered body size in bytes before compression is applied.
|
|
264
|
+
* @property {number} brotliQuality - Brotli encoder quality (0-11).
|
|
265
|
+
* @property {number} gzipLevel - Gzip compression level (0-9).
|
|
266
|
+
*/
|
|
267
|
+
|
|
252
268
|
/**
|
|
253
269
|
* @typedef {object} HttpServerConfiguration
|
|
270
|
+
* @property {boolean | HttpCompressionConfiguration} [compression] - Buffered response compression. Enabled with documented defaults when absent; false or {enabled: false} disables it globally.
|
|
254
271
|
* @property {string} [host] - Hostname to bind the HTTP server to.
|
|
255
272
|
* @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
|
|
256
273
|
* @property {number} [maxWorkers] - Backward-compatible alias for workers.
|
package/build/configuration.js
CHANGED
|
@@ -120,6 +120,10 @@ const DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES = 256
|
|
|
120
120
|
const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_BYTES = 16 * 1024 * 1024
|
|
121
121
|
const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_FRAMES = 256
|
|
122
122
|
|
|
123
|
+
const DEFAULT_COMPRESSION_THRESHOLD = 1024
|
|
124
|
+
const DEFAULT_COMPRESSION_BROTLI_QUALITY = 4
|
|
125
|
+
const DEFAULT_COMPRESSION_GZIP_LEVEL = 6
|
|
126
|
+
|
|
123
127
|
/**
|
|
124
128
|
* Validates a positive safe integer configuration value.
|
|
125
129
|
* @param {?} value - Configured positive safe integer.
|
|
@@ -136,6 +140,63 @@ function positiveSafeInteger(value, name, defaultValue) {
|
|
|
136
140
|
return value
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Validates an integer configuration value inside an inclusive range.
|
|
145
|
+
* @param {?} value - Configured integer.
|
|
146
|
+
* @param {string} name - Configuration key.
|
|
147
|
+
* @param {number} min - Minimum accepted value (inclusive).
|
|
148
|
+
* @param {number} max - Maximum accepted value (inclusive).
|
|
149
|
+
* @param {number} defaultValue - Default value.
|
|
150
|
+
* @returns {number} - Validated configured or default value.
|
|
151
|
+
*/
|
|
152
|
+
function integerInRange(value, name, min, max, defaultValue) {
|
|
153
|
+
if (value === undefined) return defaultValue
|
|
154
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
155
|
+
throw new TypeError(`${name} must be an integer between ${min} and ${max}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return value
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalizes the buffered HTTP response compression configuration. Compression is
|
|
163
|
+
* enabled by default when the setting is absent; `false` or `{enabled: false}`
|
|
164
|
+
* disables it globally.
|
|
165
|
+
* @param {boolean | import("./configuration-types.js").HttpCompressionConfiguration | undefined} value - Configured compression value.
|
|
166
|
+
* @returns {import("./configuration-types.js").NormalizedHttpCompressionConfiguration} - Normalized compression configuration.
|
|
167
|
+
*/
|
|
168
|
+
function normalizeHttpCompression(value) {
|
|
169
|
+
if (value === undefined || value === true) {
|
|
170
|
+
return {enabled: true, threshold: DEFAULT_COMPRESSION_THRESHOLD, brotliQuality: DEFAULT_COMPRESSION_BROTLI_QUALITY, gzipLevel: DEFAULT_COMPRESSION_GZIP_LEVEL}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value === false) {
|
|
174
|
+
return {enabled: false, threshold: DEFAULT_COMPRESSION_THRESHOLD, brotliQuality: DEFAULT_COMPRESSION_BROTLI_QUALITY, gzipLevel: DEFAULT_COMPRESSION_GZIP_LEVEL}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
178
|
+
throw new TypeError(`httpServer.compression must be a boolean or an object, got: ${String(value)}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const {brotliQuality, enabled, gzipLevel, threshold, ...restCompression} = value
|
|
182
|
+
const restCompressionKeys = Object.keys(restCompression)
|
|
183
|
+
|
|
184
|
+
if (restCompressionKeys.length > 0) {
|
|
185
|
+
throw new TypeError(`httpServer.compression received unknown keys: ${restCompressionKeys.join(", ")} (supported: brotliQuality, enabled, gzipLevel, threshold)`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
189
|
+
throw new TypeError(`httpServer.compression.enabled must be a boolean, got: ${String(enabled)}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
enabled: enabled ?? true,
|
|
194
|
+
threshold: positiveSafeInteger(threshold, "httpServer.compression.threshold", DEFAULT_COMPRESSION_THRESHOLD),
|
|
195
|
+
brotliQuality: integerInRange(brotliQuality, "httpServer.compression.brotliQuality", 0, 11, DEFAULT_COMPRESSION_BROTLI_QUALITY),
|
|
196
|
+
gzipLevel: integerInRange(gzipLevel, "httpServer.compression.gzipLevel", 0, 9, DEFAULT_COMPRESSION_GZIP_LEVEL)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
139
200
|
export default class VelociousConfiguration {
|
|
140
201
|
/**
|
|
141
202
|
* Close database connections promise.
|
|
@@ -236,6 +297,7 @@ export default class VelociousConfiguration {
|
|
|
236
297
|
|
|
237
298
|
this.httpServer = {
|
|
238
299
|
...(httpServer || {}),
|
|
300
|
+
compression: normalizeHttpCompression(httpServer?.compression),
|
|
239
301
|
websocketInboundQueue: {
|
|
240
302
|
maxPendingBytes: positiveSafeInteger(websocketInboundQueue?.maxPendingBytes, "httpServer.websocketInboundQueue.maxPendingBytes", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_BYTES),
|
|
241
303
|
maxPendingMessages: positiveSafeInteger(websocketInboundQueue?.maxPendingMessages, "httpServer.websocketInboundQueue.maxPendingMessages", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES)
|
|
@@ -500,6 +562,14 @@ export default class VelociousConfiguration {
|
|
|
500
562
|
return this.cors
|
|
501
563
|
}
|
|
502
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Runs get http server compression.
|
|
567
|
+
* @returns {import("./configuration-types.js").NormalizedHttpCompressionConfiguration} - Normalized buffered response compression configuration.
|
|
568
|
+
*/
|
|
569
|
+
getHttpServerCompression() {
|
|
570
|
+
return this.httpServer.compression
|
|
571
|
+
}
|
|
572
|
+
|
|
503
573
|
/**
|
|
504
574
|
* Runs get cookie secret.
|
|
505
575
|
* @returns {string | undefined} - Cookie secret.
|
|
@@ -8,6 +8,7 @@ import EventEmitter from "../../utils/event-emitter.js"
|
|
|
8
8
|
import Logger from "../../logger.js"
|
|
9
9
|
import Request from "./request.js"
|
|
10
10
|
import RequestRunner from "./request-runner.js"
|
|
11
|
+
import {applyResponseCompression} from "./response-compression.js"
|
|
11
12
|
import WebsocketSession from "./websocket-session.js"
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -38,6 +39,16 @@ export default class VeoliciousHttpServerClient {
|
|
|
38
39
|
events = new EventEmitter()
|
|
39
40
|
state = "initial"
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether a done-requests drain is currently sending responses for this client.
|
|
44
|
+
* @type {boolean} */
|
|
45
|
+
_doneRequestsDrainActive = false
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether another drain was requested while one was already active.
|
|
49
|
+
* @type {boolean} */
|
|
50
|
+
_doneRequestsDrainPending = false
|
|
51
|
+
|
|
41
52
|
/**
|
|
42
53
|
* Runs constructor.
|
|
43
54
|
* @param {object} args - Options object.
|
|
@@ -311,12 +322,38 @@ export default class VeoliciousHttpServerClient {
|
|
|
311
322
|
|
|
312
323
|
requestDone = () => {
|
|
313
324
|
this.logger.debug(() => ["requestDone", {clientCount: this.clientCount, queueLength: this.requestRunners.length}])
|
|
314
|
-
|
|
325
|
+
|
|
326
|
+
return this._drainDoneRequests().catch((error) => {
|
|
315
327
|
this.logger.warn("Failed while sending done requests", error)
|
|
316
328
|
this.events.emit("close")
|
|
317
329
|
})
|
|
318
330
|
}
|
|
319
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Drains done requests one at a time. A runner is shifted out of the queue before
|
|
334
|
+
* its response finishes sending (async compression, file transfer), so an
|
|
335
|
+
* overlapping drain would otherwise pick up the next runner and reorder pipelined
|
|
336
|
+
* socket writes. Calls that arrive while a drain is active are folded into it.
|
|
337
|
+
* @returns {Promise<void>} - Resolves when every done response has been sent.
|
|
338
|
+
*/
|
|
339
|
+
async _drainDoneRequests() {
|
|
340
|
+
if (this._doneRequestsDrainActive) {
|
|
341
|
+
this._doneRequestsDrainPending = true
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this._doneRequestsDrainActive = true
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
do {
|
|
349
|
+
this._doneRequestsDrainPending = false
|
|
350
|
+
await this.sendDoneRequests()
|
|
351
|
+
} while (this._doneRequestsDrainPending)
|
|
352
|
+
} finally {
|
|
353
|
+
this._doneRequestsDrainActive = false
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
async sendDoneRequests() {
|
|
321
358
|
while (true) {
|
|
322
359
|
const requestRunner = this.requestRunners[0]
|
|
@@ -393,6 +430,14 @@ export default class VeoliciousHttpServerClient {
|
|
|
393
430
|
// arrive — drop the body entirely for those codes.
|
|
394
431
|
const isBodylessStatus = isNoBodyStatusCode(response.getStatusCode())
|
|
395
432
|
|
|
433
|
+
// HEAD responses select and compute the exact same representation headers as the
|
|
434
|
+
// equivalent GET (including Content-Length and any negotiated Content-Encoding),
|
|
435
|
+
// but no buffered or file body is emitted below.
|
|
436
|
+
const isHeadRequest = request.httpMethod() == "HEAD"
|
|
437
|
+
|
|
438
|
+
/** @type {string | Uint8Array | null} */
|
|
439
|
+
let bodyToEmit = body
|
|
440
|
+
|
|
396
441
|
if (!isBodylessStatus) {
|
|
397
442
|
let contentLength
|
|
398
443
|
|
|
@@ -400,9 +445,34 @@ export default class VeoliciousHttpServerClient {
|
|
|
400
445
|
const stats = await fs.stat(filePath)
|
|
401
446
|
contentLength = stats.size
|
|
402
447
|
} else {
|
|
403
|
-
|
|
448
|
+
// String bodies are UTF-8 framed, so the buffered bytes are the UTF-8 encoding;
|
|
449
|
+
// Uint8Array bodies are already the exact wire bytes.
|
|
450
|
+
const bodyBuffer = bodyIsString ? Buffer.from(body, "utf8") : Buffer.from(body)
|
|
451
|
+
const compressionResult = await applyResponseCompression({
|
|
452
|
+
bodyBuffer,
|
|
453
|
+
compression: this.configuration.getHttpServerCompression(),
|
|
454
|
+
request,
|
|
455
|
+
response
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
if (compressionResult.outcome == "not-acceptable") {
|
|
459
|
+
// The client forbids identity and no supported coding is acceptable: answer
|
|
460
|
+
// with an empty 406 instead of an unacceptable representation.
|
|
461
|
+
response.setStatus(406)
|
|
462
|
+
response.setBody("")
|
|
463
|
+
bodyToEmit = ""
|
|
464
|
+
contentLength = 0
|
|
465
|
+
} else if (compressionResult.outcome == "compressed") {
|
|
466
|
+
bodyToEmit = compressionResult.body
|
|
467
|
+
contentLength = compressionResult.body.length
|
|
468
|
+
} else {
|
|
469
|
+
contentLength = bodyBuffer.length
|
|
470
|
+
}
|
|
404
471
|
}
|
|
405
472
|
|
|
473
|
+
// Remove any application pre-set Content-Length (any casing) so exactly one
|
|
474
|
+
// recomputed value goes on the wire.
|
|
475
|
+
response.removeHeader("Content-Length")
|
|
406
476
|
response.setHeader("Content-Length", contentLength)
|
|
407
477
|
}
|
|
408
478
|
|
|
@@ -427,11 +497,14 @@ export default class VeoliciousHttpServerClient {
|
|
|
427
497
|
if (isBodylessStatus) {
|
|
428
498
|
this.logger.debug(() => ["sendResponse body suppressed for no-body status", {clientCount: this.clientCount, statusCode: response.getStatusCode()}])
|
|
429
499
|
if (hasFilePath) await this.sendFileOutput(filePath, false, fileOnFinished)
|
|
500
|
+
} else if (isHeadRequest) {
|
|
501
|
+
this.logger.debug(() => ["sendResponse body suppressed for HEAD request", {clientCount: this.clientCount}])
|
|
502
|
+
if (hasFilePath) await this.sendFileOutput(filePath, false, fileOnFinished)
|
|
430
503
|
} else if (hasFilePath) {
|
|
431
504
|
await this.sendFileOutput(filePath, true, fileOnFinished)
|
|
432
505
|
} else {
|
|
433
|
-
this.events.emit("output",
|
|
434
|
-
this.logger.debug(() => ["sendResponse body emitted", {clientCount: this.clientCount, bodyLength:
|
|
506
|
+
this.events.emit("output", bodyToEmit)
|
|
507
|
+
this.logger.debug(() => ["sendResponse body emitted", {clientCount: this.clientCount, bodyLength: bodyToEmit ? bodyToEmit.length : 0}])
|
|
435
508
|
}
|
|
436
509
|
|
|
437
510
|
await requestRunner.logCompletedRequest()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import zlib from "node:zlib"
|
|
4
|
+
import {promisify} from "node:util"
|
|
5
|
+
|
|
6
|
+
const brotliCompressAsync = promisify(zlib.brotliCompress)
|
|
7
|
+
const gzipAsync = promisify(zlib.gzip)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Exact media types (beyond the text/*, *+json, and *+xml families) that are worth compressing.
|
|
11
|
+
* Everything else — unknown binary types and commonly pre-compressed media such as images,
|
|
12
|
+
* video, and archives — is left untouched by the conservative allowlist.
|
|
13
|
+
* @type {Set<string>} */
|
|
14
|
+
const COMPRESSIBLE_EXACT_MEDIA_TYPES = new Set([
|
|
15
|
+
"application/ecmascript",
|
|
16
|
+
"application/javascript",
|
|
17
|
+
"application/json",
|
|
18
|
+
"application/x-javascript",
|
|
19
|
+
"application/xml",
|
|
20
|
+
"image/svg+xml"
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* RFC 9110 §12.4.2 qvalue grammar: `0` or `1` with at most three fractional
|
|
25
|
+
* digits, and only zeros after `1`.
|
|
26
|
+
* @type {RegExp} */
|
|
27
|
+
const QVALUE_PATTERN = /^(?:0(?:\.\d{1,3})?|1(?:\.0{1,3})?)$/u
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs parse accept encoding.
|
|
31
|
+
* @param {string} headerValue - Accept-Encoding header value.
|
|
32
|
+
* @returns {Map<string, number>} - Lowercased coding to q-value (0-1).
|
|
33
|
+
*/
|
|
34
|
+
export function parseAcceptEncoding(headerValue) {
|
|
35
|
+
/** @type {Map<string, number>} */
|
|
36
|
+
const codings = new Map()
|
|
37
|
+
|
|
38
|
+
for (const part of headerValue.split(",")) {
|
|
39
|
+
const [codingToken, ...parameters] = part.split(";")
|
|
40
|
+
const coding = codingToken?.trim().toLowerCase()
|
|
41
|
+
|
|
42
|
+
if (!coding) continue
|
|
43
|
+
|
|
44
|
+
let q = 1
|
|
45
|
+
|
|
46
|
+
for (const parameter of parameters) {
|
|
47
|
+
const [name, value] = parameter.split("=")
|
|
48
|
+
|
|
49
|
+
if (name?.trim().toLowerCase() != "q") continue
|
|
50
|
+
|
|
51
|
+
const qvalue = value?.trim() || ""
|
|
52
|
+
|
|
53
|
+
// A malformed q-value (e.g. `.5`, `01`, `1.001`, more than three fractional
|
|
54
|
+
// digits, or empty) is treated as "not acceptable" (q=0), per RFC 9110 §12.4.2.
|
|
55
|
+
q = QVALUE_PATTERN.test(qvalue) ? Number(qvalue) : 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
codings.set(coding, q)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return codings
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Negotiates the content coding for a response from the Accept-Encoding header.
|
|
66
|
+
* Identity participates in the same quality comparison as the supported codings:
|
|
67
|
+
* a higher-q identity selects identity, and equal-q ties break in server order
|
|
68
|
+
* br, gzip, identity. Identity defaults to acceptable unless explicitly refused
|
|
69
|
+
* with `identity;q=0` or a `*;q=0` wildcard without a more specific identity
|
|
70
|
+
* entry, and an explicit coding entry beats the wildcard.
|
|
71
|
+
* @param {string | null | undefined} acceptEncoding - Accept-Encoding header value.
|
|
72
|
+
* @returns {{encoding: "br" | "gzip" | "identity", identityAcceptable: boolean} | {notAcceptable: true}} - Negotiated coding, or that no acceptable representation exists.
|
|
73
|
+
*/
|
|
74
|
+
export function negotiateContentEncoding(acceptEncoding) {
|
|
75
|
+
if (acceptEncoding === undefined || acceptEncoding === null || acceptEncoding.trim() === "") {
|
|
76
|
+
return {encoding: "identity", identityAcceptable: true}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const codings = parseAcceptEncoding(acceptEncoding)
|
|
80
|
+
const wildcardQ = codings.get("*")
|
|
81
|
+
const identityQ = codings.get("identity") ?? wildcardQ ?? 1
|
|
82
|
+
|
|
83
|
+
// Declared in server preference order; the stable sort keeps this order for ties.
|
|
84
|
+
/** @type {Array<{coding: "br" | "gzip" | "identity", q: number}>} */
|
|
85
|
+
const candidates = [
|
|
86
|
+
{coding: "br", q: codings.get("br") ?? wildcardQ ?? 0},
|
|
87
|
+
{coding: "gzip", q: codings.get("gzip") ?? wildcardQ ?? 0},
|
|
88
|
+
{coding: "identity", q: identityQ}
|
|
89
|
+
]
|
|
90
|
+
const selected = candidates
|
|
91
|
+
.filter((candidate) => candidate.q > 0)
|
|
92
|
+
.sort((a, b) => b.q - a.q)[0]
|
|
93
|
+
|
|
94
|
+
if (!selected) return {notAcceptable: true}
|
|
95
|
+
|
|
96
|
+
return {encoding: selected.coding, identityAcceptable: identityQ > 0}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Runs is compressible content type.
|
|
101
|
+
* @param {string} contentType - Content-Type header value.
|
|
102
|
+
* @returns {boolean} - Whether the media type is on the compressible allowlist.
|
|
103
|
+
*/
|
|
104
|
+
export function isCompressibleContentType(contentType) {
|
|
105
|
+
const mediaType = contentType.split(";")[0]?.trim().toLowerCase()
|
|
106
|
+
|
|
107
|
+
if (!mediaType) return false
|
|
108
|
+
|
|
109
|
+
// Server-sent events are long-lived streams; buffering them for compression
|
|
110
|
+
// would break delivery, so they are excluded before the textual allowlist.
|
|
111
|
+
if (mediaType == "text/event-stream") return false
|
|
112
|
+
if (mediaType.startsWith("text/")) return true
|
|
113
|
+
if (mediaType.endsWith("+json") || mediaType.endsWith("+xml")) return true
|
|
114
|
+
|
|
115
|
+
return COMPRESSIBLE_EXACT_MEDIA_TYPES.has(mediaType)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Merges Accept-Encoding into the response Vary header case-insensitively and
|
|
120
|
+
* without duplicates. An existing `Vary: *` already covers every request header
|
|
121
|
+
* and is preserved as-is.
|
|
122
|
+
* @param {import("./response.js").default} response - Response instance.
|
|
123
|
+
* @returns {void} - No return value.
|
|
124
|
+
*/
|
|
125
|
+
export function addAcceptEncodingToVary(response) {
|
|
126
|
+
for (const headerKey in response.headers) {
|
|
127
|
+
if (headerKey.toLowerCase() != "vary") continue
|
|
128
|
+
|
|
129
|
+
const values = response.headers[headerKey]
|
|
130
|
+
const tokens = values.flatMap((value) => value.split(",").map((token) => token.trim().toLowerCase()))
|
|
131
|
+
|
|
132
|
+
if (tokens.includes("*") || tokens.includes("accept-encoding")) return
|
|
133
|
+
|
|
134
|
+
if (values.length > 0) {
|
|
135
|
+
values[0] = `${values[0]}, Accept-Encoding`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
response.setHeader("Vary", "Accept-Encoding")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Negotiates and applies compression to a buffered response body immediately before
|
|
146
|
+
* framing. Only string/Uint8Array responses reach this point; sendFile responses and
|
|
147
|
+
* bodyless statuses are excluded by the caller. Transformation is skipped for
|
|
148
|
+
* Cache-Control no-transform, non-allowlisted or pre-compressed media types,
|
|
149
|
+
* server-sent events, partial (206) responses, requests with a Range header,
|
|
150
|
+
* credentialed requests (Authorization/Cookie headers), responses carrying
|
|
151
|
+
* credentials or validators (Set-Cookie/ETag/Digest/Content-Digest/Content-Range
|
|
152
|
+
* headers), and per-response opt-outs; a skipped
|
|
153
|
+
* transformation is still sent as identity when the client accepts identity, and
|
|
154
|
+
* answered "not-acceptable" when it does not. Responses that already carry an
|
|
155
|
+
* application-supplied Content-Encoding are passed through unchanged and never
|
|
156
|
+
* negotiate. When the client forbids every representation (identity and all
|
|
157
|
+
* supported codings), the outcome is "not-acceptable".
|
|
158
|
+
* @param {object} args - Options object.
|
|
159
|
+
* @param {Buffer} args.bodyBuffer - Buffered response body bytes (UTF-8 encoded for string bodies).
|
|
160
|
+
* @param {import("../../configuration-types.js").NormalizedHttpCompressionConfiguration} args.compression - Normalized compression configuration.
|
|
161
|
+
* @param {import("./request.js").default | import("./websocket-request.js").default} args.request - Request object.
|
|
162
|
+
* @param {import("./response.js").default} args.response - Response instance.
|
|
163
|
+
* @returns {Promise<{outcome: "identity"} | {outcome: "compressed", body: Buffer} | {outcome: "not-acceptable"}>} - Compression outcome.
|
|
164
|
+
*/
|
|
165
|
+
export async function applyResponseCompression({bodyBuffer, compression, request, response}) {
|
|
166
|
+
if (!compression.enabled) return {outcome: "identity"}
|
|
167
|
+
|
|
168
|
+
// Application-supplied encodings stay application-owned: they are passed through
|
|
169
|
+
// unchanged and never take part in negotiation failure handling.
|
|
170
|
+
if (response.getHeader("Content-Encoding").length > 0) return {outcome: "identity"}
|
|
171
|
+
|
|
172
|
+
const negotiated = negotiateContentEncoding(request.header("accept-encoding"))
|
|
173
|
+
|
|
174
|
+
if ("notAcceptable" in negotiated) return {outcome: "not-acceptable"}
|
|
175
|
+
|
|
176
|
+
const cacheControlTokens = response.getHeader("Cache-Control")
|
|
177
|
+
.flatMap((value) => value.split(","))
|
|
178
|
+
.map((token) => token.trim().toLowerCase())
|
|
179
|
+
const contentType = response.getHeader("Content-Type")[0]
|
|
180
|
+
|
|
181
|
+
// Automatic security exclusions: credentialed requests (Authorization/Cookie) and
|
|
182
|
+
// responses carrying credentials (Set-Cookie) or representation validators
|
|
183
|
+
// (ETag/Digest/Content-Digest) are never transformed — compression could leak
|
|
184
|
+
// secret-bearing content through a compression oracle, and validators stay
|
|
185
|
+
// application-owned rather than being recomputed for encoded variants.
|
|
186
|
+
const transformable = !response.isCompressionDisabled() &&
|
|
187
|
+
response.getStatusCode() !== 206 &&
|
|
188
|
+
!request.header("range") &&
|
|
189
|
+
!request.header("authorization") &&
|
|
190
|
+
!request.header("cookie") &&
|
|
191
|
+
response.getHeader("Content-Range").length === 0 &&
|
|
192
|
+
response.getHeader("Set-Cookie").length === 0 &&
|
|
193
|
+
response.getHeader("ETag").length === 0 &&
|
|
194
|
+
response.getHeader("Digest").length === 0 &&
|
|
195
|
+
response.getHeader("Content-Digest").length === 0 &&
|
|
196
|
+
!cacheControlTokens.includes("no-transform") &&
|
|
197
|
+
contentType !== undefined &&
|
|
198
|
+
isCompressibleContentType(contentType)
|
|
199
|
+
|
|
200
|
+
if (!transformable) {
|
|
201
|
+
// A skipped transformation may still go out as identity when the client accepts
|
|
202
|
+
// identity; when identity is forbidden, no acceptable representation can be sent.
|
|
203
|
+
return negotiated.identityAcceptable ? {outcome: "identity"} : {outcome: "not-acceptable"}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The representation now depends on the request's Accept-Encoding, even when this
|
|
207
|
+
// particular response ends up identity (missing header, higher-q identity, below threshold).
|
|
208
|
+
addAcceptEncodingToVary(response)
|
|
209
|
+
|
|
210
|
+
if (negotiated.encoding == "identity") return {outcome: "identity"}
|
|
211
|
+
|
|
212
|
+
// Below the threshold the smaller identity representation is sent instead — but only
|
|
213
|
+
// when identity is acceptable; a client that forbids identity must never be forced
|
|
214
|
+
// onto an unacceptable representation by the size check.
|
|
215
|
+
if (bodyBuffer.length < compression.threshold && negotiated.identityAcceptable) return {outcome: "identity"}
|
|
216
|
+
|
|
217
|
+
const body = negotiated.encoding == "br"
|
|
218
|
+
? await brotliCompressAsync(bodyBuffer, {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: compression.brotliQuality}})
|
|
219
|
+
: await gzipAsync(bodyBuffer, {level: compression.gzipLevel})
|
|
220
|
+
|
|
221
|
+
response.setHeader("Content-Encoding", negotiated.encoding)
|
|
222
|
+
|
|
223
|
+
return {body, outcome: "compressed"}
|
|
224
|
+
}
|
|
@@ -98,6 +98,11 @@ export default class VelociousHttpServerClientResponse {
|
|
|
98
98
|
* @type {Record<string, string[]>} */
|
|
99
99
|
headers = {}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Whether compression has been disabled for this specific response.
|
|
103
|
+
* @type {boolean} */
|
|
104
|
+
compressionDisabled = false
|
|
105
|
+
|
|
101
106
|
/**
|
|
102
107
|
* Runs constructor.
|
|
103
108
|
* @param {object} args - Options object.
|
|
@@ -133,6 +138,58 @@ export default class VelociousHttpServerClientResponse {
|
|
|
133
138
|
this.headers[key] = [value]
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Returns every value set for a header, matched case-insensitively.
|
|
143
|
+
* @param {string} key - Header name.
|
|
144
|
+
* @returns {string[]} - Header values in insertion order.
|
|
145
|
+
*/
|
|
146
|
+
getHeader(key) {
|
|
147
|
+
const lowerCaseKey = key.toLowerCase()
|
|
148
|
+
|
|
149
|
+
/** @type {string[]} */
|
|
150
|
+
const values = []
|
|
151
|
+
|
|
152
|
+
for (const headerKey in this.headers) {
|
|
153
|
+
if (headerKey.toLowerCase() == lowerCaseKey) {
|
|
154
|
+
values.push(...this.headers[headerKey])
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return values
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Removes every value set for a header, matched case-insensitively.
|
|
163
|
+
* @param {string} key - Header name.
|
|
164
|
+
* @returns {void} - No return value.
|
|
165
|
+
*/
|
|
166
|
+
removeHeader(key) {
|
|
167
|
+
const lowerCaseKey = key.toLowerCase()
|
|
168
|
+
|
|
169
|
+
for (const headerKey in this.headers) {
|
|
170
|
+
if (headerKey.toLowerCase() == lowerCaseKey) {
|
|
171
|
+
delete this.headers[headerKey]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Disables HTTP response compression for this specific response, even when the
|
|
178
|
+
* server is configured to compress buffered responses.
|
|
179
|
+
* @returns {void} - No return value.
|
|
180
|
+
*/
|
|
181
|
+
disableCompression() {
|
|
182
|
+
this.compressionDisabled = true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Runs is compression disabled.
|
|
187
|
+
* @returns {boolean} - Whether compression has been disabled for this response.
|
|
188
|
+
*/
|
|
189
|
+
isCompressionDisabled() {
|
|
190
|
+
return this.compressionDisabled
|
|
191
|
+
}
|
|
192
|
+
|
|
136
193
|
/**
|
|
137
194
|
* Runs get body.
|
|
138
195
|
* @returns {string | Uint8Array | null} - The body.
|