redweb 0.7.7 → 0.9.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/CHANGELOG.md +11 -0
- package/README.md +458 -276
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/index.d.ts +308 -60
- package/index.js +24 -6
- package/package.json +25 -6
- package/src/htmx/HtmxRenderer.js +10 -2
- package/src/http/BaseHttpServer.js +84 -29
- package/src/http/HttpServer.js +18 -10
- package/src/http/HttpsServer.js +19 -11
- package/src/serverLifecycle.js +46 -0
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -32
- package/src/ws/BaseSocketServer.js +182 -19
- package/src/ws/DefaultHandler.js +2 -3
- package/src/ws/DefaultRoute.js +4 -3
- package/src/ws/DistributionBridge.js +271 -0
- package/src/ws/FixedStepService.js +74 -0
- package/src/ws/HeartbeatMonitor.js +75 -0
- package/src/ws/Metrics.js +34 -0
- package/src/ws/ProtocolPolicy.js +130 -0
- package/src/ws/RoomRegistry.js +117 -0
- package/src/ws/RouteRuntime.js +146 -0
- package/src/ws/SecureSocketServer.js +9 -12
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +503 -116
- package/src/ws/SocketServer.js +8 -11
- package/src/ws/TaskQueue.js +64 -0
- package/src/ws/TokenBucket.js +31 -0
- package/src/ws/TransportPolicy.js +68 -0
- package/src/ws/index.js +7 -2
- package/src/ws/protocol-schema.json +13 -0
- package/src/ws/protocol-validation.js +21 -0
- package/src/ws/shutdown.js +33 -0
- package/src/ws/util.js +34 -5
package/client.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Generated from src/ws/protocol-schema.json by scripts/generate-protocol-types.js.
|
|
2
|
+
export type RedWebProtocolErrorCode =
|
|
3
|
+
| 'INVALID_MESSAGE'
|
|
4
|
+
| 'UNKNOWN_HANDLER'
|
|
5
|
+
| 'HANDLER_FAILED'
|
|
6
|
+
| 'BINARY_UNSUPPORTED'
|
|
7
|
+
| 'RATE_LIMITED'
|
|
8
|
+
| 'QUEUE_FULL'
|
|
9
|
+
| 'CAPACITY_REACHED'
|
|
10
|
+
| 'INITIALIZATION_FAILED';
|
|
11
|
+
|
|
12
|
+
export interface ProtocolMetadata {
|
|
13
|
+
requestId?: string;
|
|
14
|
+
sequence?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProtocolEnvelope<T = unknown> extends ProtocolMetadata {
|
|
18
|
+
v: string;
|
|
19
|
+
type: string;
|
|
20
|
+
payload: T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProtocolErrorEnvelope extends ProtocolMetadata {
|
|
24
|
+
v: string;
|
|
25
|
+
type: 'error';
|
|
26
|
+
error: { code: RedWebProtocolErrorCode | string; message: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SendableSocket {
|
|
30
|
+
send(data: string): unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ProtocolClient {
|
|
34
|
+
constructor(socket: SendableSocket, version: string);
|
|
35
|
+
readonly socket: SendableSocket;
|
|
36
|
+
readonly version: string;
|
|
37
|
+
envelope<T>(type: string, payload: T, metadata?: ProtocolMetadata): ProtocolEnvelope<T>;
|
|
38
|
+
send<T>(type: string, payload: T, metadata?: ProtocolMetadata): void;
|
|
39
|
+
parse<T = unknown>(input: string | Uint8Array | ArrayBuffer | { data: string | Uint8Array | ArrayBuffer }): ProtocolEnvelope<T> | ProtocolErrorEnvelope;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ERROR_CODES: Readonly<Record<RedWebProtocolErrorCode, RedWebProtocolErrorCode>>;
|
package/client.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const schema = require('./src/ws/protocol-schema.json');
|
|
2
|
+
const { validateEnvelope } = require('./src/ws/protocol-validation');
|
|
3
|
+
|
|
4
|
+
const ERROR_CODES = Object.freeze(Object.fromEntries(schema.errorCodes.map(code => [code, code])));
|
|
5
|
+
|
|
6
|
+
function boundedString(value, name, maxLength) {
|
|
7
|
+
if (typeof value !== 'string' || !value || value.length > maxLength) {
|
|
8
|
+
throw new TypeError(`${name} must be a non-empty string of at most ${maxLength} characters.`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class ProtocolClient {
|
|
14
|
+
constructor(socket, version) {
|
|
15
|
+
if (!socket || typeof socket.send !== 'function') throw new TypeError('socket must provide send(data).');
|
|
16
|
+
this.socket = socket;
|
|
17
|
+
this.version = boundedString(version, 'version', 64);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
envelope(type, payload, metadata = {}) {
|
|
21
|
+
const message = { v: this.version, type: boundedString(type, 'type', 256), payload };
|
|
22
|
+
if (metadata.requestId !== undefined) {
|
|
23
|
+
message.requestId = boundedString(metadata.requestId, 'requestId', 256);
|
|
24
|
+
}
|
|
25
|
+
if (metadata.sequence !== undefined) {
|
|
26
|
+
if (!Number.isSafeInteger(metadata.sequence) || metadata.sequence < 0) {
|
|
27
|
+
throw new TypeError('sequence must be a non-negative safe integer.');
|
|
28
|
+
}
|
|
29
|
+
message.sequence = metadata.sequence;
|
|
30
|
+
}
|
|
31
|
+
return message;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
send(type, payload, metadata) {
|
|
35
|
+
this.socket.send(JSON.stringify(this.envelope(type, payload, metadata)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parse(input) {
|
|
39
|
+
const raw = input && typeof input === 'object' && 'data' in input ? input.data : input;
|
|
40
|
+
let serialized;
|
|
41
|
+
if (typeof raw === 'string') serialized = raw;
|
|
42
|
+
else if (raw instanceof ArrayBuffer) serialized = new TextDecoder().decode(new Uint8Array(raw));
|
|
43
|
+
else if (ArrayBuffer.isView(raw)) serialized = new TextDecoder().decode(
|
|
44
|
+
new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
45
|
+
);
|
|
46
|
+
else serialized = raw.toString();
|
|
47
|
+
const message = JSON.parse(serialized);
|
|
48
|
+
if (!validateEnvelope(message, this.version)) {
|
|
49
|
+
throw new TypeError('Received an invalid Redweb protocol envelope.');
|
|
50
|
+
}
|
|
51
|
+
return message;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { ProtocolClient, ERROR_CODES };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Multiplayer operations
|
|
2
|
+
|
|
3
|
+
Redweb exposes small composition points and leaves deployment policy to the game. These examples are deliberately infrastructure-neutral.
|
|
4
|
+
|
|
5
|
+
## Readiness and shutdown
|
|
6
|
+
|
|
7
|
+
Expose `socketServer.isReady()` from the HTTP stack used by the orchestrator. On termination, call `beginDrain()` first, stop external placement to the node, then call and await `shutdown()`. New upgrades receive `503` after draining begins.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
app.get('/ready', (_request, response) => {
|
|
11
|
+
response.sendStatus(socketServer.isReady() ? 200 : 503)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
process.once('SIGTERM', async () => {
|
|
15
|
+
socketServer.beginDrain()
|
|
16
|
+
await socketServer.shutdown()
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If `drainHandlers` is enabled, handlers should observe `socket.context.signal` and return promptly. Set the platform termination grace period above the application's maximum cooperative handler time plus `shutdownTimeoutMs`.
|
|
21
|
+
|
|
22
|
+
## Placement and partitions
|
|
23
|
+
|
|
24
|
+
The admission `place(principal, request, context)` hook can return another node's `ws`/`wss` URL before upgrade. Keep placement decisions short-lived and retryable. A redirect is not a reservation: the destination must still authenticate, enforce capacity, and reject stale placement.
|
|
25
|
+
|
|
26
|
+
Treat the distribution adapter as ephemeral fan-out. During broker or network partitions, pause affected matches, continue from one authoritative owner, or reconcile from durable application state. Do not treat adapter delivery or its bounded deduplication window as persistence.
|
|
27
|
+
|
|
28
|
+
Adapter lifecycle and publish methods receive an optional final `AbortSignal`. Observe it in broker clients that support cancellation. Redweb compensates late startup and subscription completion, but a publish that the broker has already accepted cannot be recalled.
|
|
29
|
+
|
|
30
|
+
## Capacity signals
|
|
31
|
+
|
|
32
|
+
Alert on rejected connections, rate-limited messages, full queues, handler failures, active connections, and readiness. Metrics intentionally contain only the route label. Join high-cardinality player, room, and match diagnostics in application logs or traces under the studio's own privacy and retention policy.
|
|
33
|
+
|
|
34
|
+
Size `maxConnections`, `maxBufferedBytes`, `maxPendingMessages`, room limits, session limits, adapter event limits, and codec byte limits from measured budgets. Run the included verification scripts on the deployment's Node version and instance class before changing those ceilings.
|
|
35
|
+
|
|
36
|
+
Redweb limits the number and lifetime of session records, but it deliberately does not inspect application session data. Keep that data small, schema-validated, and free of authoritative state that belongs in durable storage.
|
|
37
|
+
|
|
38
|
+
## Verification
|
|
39
|
+
|
|
40
|
+
Run `npm test` for unit, real HTTP/WebSocket/WSS integration, fuzz, type-generation, and 100% coverage gates. The additional production gates are:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm run verify:load
|
|
44
|
+
npm run verify:memory
|
|
45
|
+
npm run verify:recovery
|
|
46
|
+
npm run verify:soak
|
|
47
|
+
npm run verify:overhead -- /path/to/redweb-0.8-baseline
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The soak defaults to 60 minutes. Shorter durations are useful for CI smoke checks but are not release evidence.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Multiplayer production-readiness contract
|
|
2
|
+
|
|
3
|
+
Redweb remains a small transport foundation. Applications own game rules, authoritative state, matchmaking, databases, and identity providers. Redweb owns bounded connection admission, delivery, grouping, lifecycle, and optional composition points.
|
|
4
|
+
|
|
5
|
+
## Compatibility invariants
|
|
6
|
+
|
|
7
|
+
- Every production feature is opt-in.
|
|
8
|
+
- Existing route, handler, and service subclasses require no source changes.
|
|
9
|
+
- The default route, strict routing, IP collision policy, handler dispatch, error hiding, listener ownership, and shutdown behavior remain compatible with 0.8.
|
|
10
|
+
- Disabled multiplayer features create no timers or per-connection queues.
|
|
11
|
+
- No global mutable registry or mandatory infrastructure dependency is permitted.
|
|
12
|
+
- Every timer, listener, queued task, membership, session lease, and adapter subscription has one deterministic cleanup owner.
|
|
13
|
+
- User hooks may be synchronous or asynchronous and may not escape as process-level failures.
|
|
14
|
+
- Cleanup is bounded, idempotent, best-effort, and continues after individual failures. Owned listeners also terminate incomplete HTTP peers at the route deadline; borrowed listeners remain application-owned.
|
|
15
|
+
|
|
16
|
+
## Delivery claims
|
|
17
|
+
|
|
18
|
+
WebSocket provides an ordered byte stream while a connection remains healthy. Redweb does not claim exactly-once delivery. Reconnection, distributed adapters, and application retries can introduce loss or duplication; protocol users must use explicitly scoped sequence identifiers when those cases matter.
|
|
19
|
+
|
|
20
|
+
## Roadmap gates
|
|
21
|
+
|
|
22
|
+
1. **Bounded transport:** pre-upgrade admission, origin policy, rate limits, slow-consumer enforcement, bounded ordered processing, payload limits, and route-level heartbeat.
|
|
23
|
+
2. **Multiplayer grouping:** route-scoped rooms, atomic membership cleanup, bounded session resumption, fixed-step services, and vendor-neutral metrics.
|
|
24
|
+
3. **Horizontal composition:** draining/readiness, adapter lifecycle, loop prevention, bounded validation, placement hooks, and documented partition behavior.
|
|
25
|
+
4. **Protocol and clients:** version negotiation, stable envelopes and error codes, generated client-facing types, binary replication hooks, and operational examples.
|
|
26
|
+
|
|
27
|
+
## Release gates
|
|
28
|
+
|
|
29
|
+
- Existing tests and documented examples run unchanged.
|
|
30
|
+
- New behavior has unit tests and mock-free HTTP/WS/WSS integration tests.
|
|
31
|
+
- Coverage remains 100% for statements, branches, functions, and lines.
|
|
32
|
+
- Disabled-feature throughput regression is at most 3%; p99 latency regression is at most 5% on the same machine and Node version.
|
|
33
|
+
- Heartbeat uses one scheduler per route, never one interval per connection.
|
|
34
|
+
- Every queue, retained session, room, adapter backlog, and deduplication window is finite.
|
|
35
|
+
- Broadcast serializes once and remains O(n) in selected recipients.
|
|
36
|
+
- Slow clients cannot grow framework-owned memory without bound.
|
|
37
|
+
- A 60-minute soak shows no monotonic growth in timers, listeners, rooms, sessions, or queues.
|
|
38
|
+
- Reconnect storms return retained heap to within 10% of the warmed baseline after expiry and forced collection.
|
|
39
|
+
- Readiness becomes false before draining and shutdown completes within its documented bound.
|
|
40
|
+
|
|
41
|
+
The independent senior-review gate rejects releases that weaken any invariant, hide ambiguous delivery semantics, add mandatory brokers or identity libraries, or substitute coverage percentages for race, load, soak, and failure evidence.
|
|
42
|
+
|
|
43
|
+
## Horizontal composition contract
|
|
44
|
+
|
|
45
|
+
- Placement runs before upgrade within the admission timeout. Redirects must use `wss`, contain no credentials or fragment, and may be restricted with `allowedPlacementOrigins`. Plain `ws` placement requires the explicit `allowInsecurePlacement` escape hatch for private development networks.
|
|
46
|
+
- Readiness becomes false before shutdown work begins. New upgrades receive `503`; existing connections stop accepting messages.
|
|
47
|
+
- `drainHandlers` is opt-in. When enabled, every connection context shares the route drain signal and shutdown awaits tracked work. Application handlers remain responsible for observing the signal; non-cooperating promises cannot be forcibly cancelled.
|
|
48
|
+
- Distribution adapters have no framework backlog. Publish and inbound concurrency are finite; publish failure returns `false`; startup, subscription, unsubscription, draining, and close are bounded. Adapter operations receive an optional `AbortSignal`, and late startup/subscription settlement is compensated. Adapters must observe the signal when their external side effects are not otherwise reversible.
|
|
49
|
+
- A failed publish marks a `required` adapter unhealthy, makes the route unready, and causes new upgrades to receive `503`; a later successful publish restores health. Best-effort adapters do not affect route readiness.
|
|
50
|
+
- Event IDs are deduplicated only inside a finite TTL/size window. Source-node events are ignored to prevent reflection loops.
|
|
51
|
+
- Broker partitions and process failure can lose events. Redweb makes no exactly-once or durable-delivery claim; applications own authoritative persistence, reconciliation, tick/sequence semantics, and partition policy.
|
|
52
|
+
|
|
53
|
+
## Protocol contract
|
|
54
|
+
|
|
55
|
+
- Negotiation is opt-in and happens before upgrade. Unsupported clients receive `426` plus the finite supported-version list.
|
|
56
|
+
- JSON events use `{ v, type, payload, requestId?, sequence? }`. Error events use `{ v, type: "error", error: { code, message }, requestId? }`.
|
|
57
|
+
- `requestId` correlates a request and response; `sequence` expresses application ordering. Neither implies acknowledgement, durability, or exactly-once delivery.
|
|
58
|
+
- Stable framework codes are generated from `src/ws/protocol-schema.json`; the client declarations and runtime constants share that source.
|
|
59
|
+
- Binary replication is a codec hook, not a codec dependency. Size is checked before decode and after encode, and outbound data uses the normal backpressure ceiling.
|
|
60
|
+
- Protocol-disabled routes retain their 0.8 wire shapes and allocate no protocol context.
|
|
61
|
+
|
|
62
|
+
## Resource ownership
|
|
63
|
+
|
|
64
|
+
- `maxPendingUpgrades` bounds authorization work before a socket is accepted.
|
|
65
|
+
- Timed-out admission hooks that ignore cancellation retain their reservation until they actually settle, preventing repeated timeout waves from accumulating unbounded application work.
|
|
66
|
+
- Fixed-step services clamp retained lag with `maxRetainedLagMs`; dropped time is observable rather than replayed forever.
|
|
67
|
+
- Session count, ID length, and lifetime are bounded by Redweb. Session `data` is application-owned, so applications must validate and cap its shape and byte size before storing it.
|
|
68
|
+
- Fully enabled idle routes have a 2 KiB framework-metadata budget per connection. Disabled features retain the legacy path and are compared against 0.8 by the performance gate.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 0.9.0 verification evidence
|
|
2
|
+
|
|
3
|
+
Release-candidate measurements were taken on Windows x64, Node 22.21.0, and an AMD Ryzen 7 7800X3D. Performance numbers are machine-specific; the scripts and thresholds are the durable contract.
|
|
4
|
+
|
|
5
|
+
## Automated correctness
|
|
6
|
+
|
|
7
|
+
- 290 unit and mock-free integration/fuzz tests pass on Node 18, 20, and 22.
|
|
8
|
+
- Statements, branches, functions, and lines are each 100% covered.
|
|
9
|
+
- Type declarations compile and generated protocol declarations match their schema.
|
|
10
|
+
|
|
11
|
+
## Resource and failure gates
|
|
12
|
+
|
|
13
|
+
- Real-socket load: 32 concurrent clients, 3,200 request/response messages, 6,874 messages/second, 5.87 ms p99, with a paused slow consumer disconnected by the outbound-buffer policy.
|
|
14
|
+
- Reconnect recovery: 200 warm connections followed by 1,200 storm connections; retained heap recovered to 104.33% of warm baseline and connection, room, and session registries returned to zero.
|
|
15
|
+
- Fully enabled idle-route metadata: 1,756.93 bytes per connection across the median of three 500-connection trials, below the 2,048-byte gate.
|
|
16
|
+
- Disabled-feature comparison with Redweb 0.8: throughput improved 1.09% and p99 regressed 2.00% (limits: 3% and 5%), using five alternating 20,000-message trials at concurrency 128.
|
|
17
|
+
- `npm audit` reports zero vulnerabilities after upgrading Express to 4.22.2, `ws` to 8.21.3, and patched transitive dependencies.
|
|
18
|
+
- The corrected 60-minute soak sent 2,099,717 messages across 64 rotating clients and received 2,099,565 responses (99.993%). Steady-state clients, rooms, sessions, in-flight work, queued work, listeners, and Redweb-owned timers showed no sustained growth; late-window heap was 3.24% above the early window, within the 10% gate. Final heap was 100.04% of the warmed baseline; all registries returned to zero; active handles stayed within the one-handle allowance.
|
|
19
|
+
|
|
20
|
+
All measurements above were rerun on the final release candidate. Shortened soak smoke runs are not counted as release evidence.
|