redweb 0.12.0 → 0.13.1

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.
Files changed (169) hide show
  1. package/CHANGELOG.md +170 -9
  2. package/README.md +283 -629
  3. package/bin/redweb.js +11 -20
  4. package/client.d.ts +7 -2
  5. package/config/tsconfig.json +14 -14
  6. package/contract.d.ts +45 -0
  7. package/contract.js +5 -0
  8. package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
  9. package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
  10. package/docs/AGENT_ACCESS.md +35 -0
  11. package/docs/AGENT_EVALUATION.md +58 -0
  12. package/docs/AGENT_READY_ACCEPTANCE.md +778 -0
  13. package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
  14. package/docs/BENCHMARK_VERIFICATION.md +307 -0
  15. package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
  16. package/docs/CLI.md +116 -0
  17. package/docs/CLIENT_DEVELOPMENT.md +152 -0
  18. package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
  19. package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
  20. package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
  21. package/docs/DEVELOPMENT.md +79 -0
  22. package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
  23. package/docs/DOCUMENTATION.md +37 -0
  24. package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
  25. package/docs/GETTING_STARTED.md +58 -0
  26. package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
  27. package/docs/LIVE_HTML.md +169 -21
  28. package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
  29. package/docs/MIGRATION.md +28 -0
  30. package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
  31. package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
  32. package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
  33. package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
  34. package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
  35. package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
  36. package/docs/PRODUCTION_READINESS.md +12 -2
  37. package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
  38. package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
  39. package/docs/RECOVERY_CODE_CENSUS.md +158 -0
  40. package/docs/RECOVERY_COMPARISON.md +103 -0
  41. package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
  42. package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
  43. package/docs/RECOVERY_INVESTIGATION.md +229 -0
  44. package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
  45. package/docs/RELEASE_TRUST.md +61 -0
  46. package/docs/ROOM_AUTHORIZATION.md +49 -0
  47. package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
  48. package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
  49. package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
  50. package/docs/SOAK_VERIFICATION.md +154 -0
  51. package/docs/SOCKET_CONTRACTS.md +39 -0
  52. package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
  53. package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
  54. package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
  55. package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
  56. package/docs/STARTER_REPORT_RETENTION.md +73 -0
  57. package/docs/VERIFICATION_EVIDENCE.md +2 -0
  58. package/docs/generated.json +2154 -0
  59. package/docs/guides/chatroom.md +27 -0
  60. package/docs/guides/http-websocket.md +28 -0
  61. package/docs/guides/jsx-without-react.md +26 -0
  62. package/docs/guides/realtime-dashboard.md +29 -0
  63. package/docs/guides/typed-websockets.md +26 -0
  64. package/docs/reference.json +1207 -0
  65. package/docs/releases/0.13.0.json +2154 -0
  66. package/docs/releases/audit-0.13.0.json +1549 -0
  67. package/docs/snippets/room-access.tsx +51 -0
  68. package/docs/topics.json +21 -0
  69. package/examples/live-html/chatroom.js +207 -268
  70. package/examples/live-html/chatroom.tsx +167 -0
  71. package/examples/live-html/jsx-page.js +1 -1
  72. package/examples/live-html/jsx-page.tsx +1 -1
  73. package/examples/live-html/tsconfig.json +8 -7
  74. package/index.d.ts +170 -45
  75. package/index.js +2 -0
  76. package/jsx-dev-runtime.js +2 -2
  77. package/jsx-runtime.d.ts +7 -2
  78. package/package.json +88 -7
  79. package/recipes/add/artifact.test.cjs +57 -0
  80. package/recipes/add/live.tsx +18 -0
  81. package/recipes/add/socket-route.ts +24 -0
  82. package/recipes/chat/README.md +22 -0
  83. package/recipes/chat/app.test.cjs +105 -0
  84. package/recipes/chat/app.tsx +9 -0
  85. package/recipes/dashboard/README.md +43 -0
  86. package/recipes/dashboard/admin.ts +21 -0
  87. package/recipes/dashboard/app.css +16 -0
  88. package/recipes/dashboard/app.test.cjs +450 -0
  89. package/recipes/dashboard/app.tsx +86 -0
  90. package/recipes/dashboard/auth.ts +80 -0
  91. package/recipes/dashboard/cards.tsx +102 -0
  92. package/recipes/dashboard/rate-window.test.cjs +17 -0
  93. package/recipes/dashboard/store.ts +120 -0
  94. package/recipes/http-ws/README.md +11 -0
  95. package/recipes/http-ws/app.test.cjs +92 -0
  96. package/recipes/http-ws/app.tsx +36 -0
  97. package/recipes/realtime/README.md +8 -0
  98. package/recipes/realtime/app.test.cjs +15 -0
  99. package/recipes/realtime/app.tsx +28 -0
  100. package/recipes/shared/README.md +40 -0
  101. package/recipes/shared/app.css +8 -0
  102. package/recipes/shared/copy-assets.cjs +8 -0
  103. package/recipes/shared/network.cjs +59 -0
  104. package/recipes/shared/run-app.test.cjs +158 -0
  105. package/recipes/shared/run-app.ts +50 -0
  106. package/recipes/site/README.md +4 -0
  107. package/recipes/site/app.test.cjs +19 -0
  108. package/recipes/site/app.tsx +25 -0
  109. package/recipes/socket/README.md +39 -0
  110. package/recipes/socket/app.test.cjs +85 -0
  111. package/recipes/socket/app.tsx +30 -0
  112. package/recipes/socket/contract.ts +12 -0
  113. package/recipes/socket/handlers.ts +40 -0
  114. package/src/OwnedServerLifecycle.js +66 -0
  115. package/src/access/AccessPolicy.js +37 -0
  116. package/src/access/AuthenticationFailure.js +13 -0
  117. package/src/access/RequestFailure.js +33 -0
  118. package/src/access/failure-codes.json +25 -0
  119. package/src/async/BoundedOperation.js +62 -0
  120. package/src/cli/ActionReferences.js +193 -0
  121. package/src/cli/AdditionLayout.js +140 -0
  122. package/src/cli/FilePlan.js +94 -0
  123. package/src/cli/ProjectAddition.js +60 -0
  124. package/src/cli/ProjectConfig.js +26 -0
  125. package/src/cli/ProjectDoctor.js +112 -0
  126. package/src/cli/ProjectInitializer.js +20 -30
  127. package/src/cli/SourceInspector.js +207 -0
  128. package/src/cli/StaticSource.js +192 -0
  129. package/src/cli/arguments.js +62 -0
  130. package/src/cli/formatCommand.js +10 -0
  131. package/src/cli/run.js +57 -0
  132. package/src/cli/templates.js +86 -87
  133. package/src/context/RequestSnapshot.js +41 -0
  134. package/src/dataProperty.js +11 -0
  135. package/src/development/DevelopmentPageManager.js +48 -0
  136. package/src/development/Inspection.js +104 -0
  137. package/src/development/ObservedRenderer.js +42 -0
  138. package/src/development/description.js +35 -0
  139. package/src/development/loopbackRequest.js +27 -0
  140. package/src/development/refreshBrowser.js +96 -0
  141. package/src/development/refreshStyles.js +9 -0
  142. package/src/development/settings.js +17 -0
  143. package/src/docs/Documentation.js +182 -0
  144. package/src/htmx/ActionDefinition.js +44 -0
  145. package/src/htmx/Jsx.js +24 -8
  146. package/src/htmx/LiveHtmlServer.js +41 -19
  147. package/src/htmx/LivePage.js +63 -13
  148. package/src/htmx/PageIdentity.js +32 -0
  149. package/src/htmx/PageLifetime.js +37 -0
  150. package/src/htmx/PageManager.js +203 -74
  151. package/src/htmx/ReactiveRenderer.js +241 -0
  152. package/src/htmx/StaticExporter.js +1 -1
  153. package/src/htmx/TemplateRenderer.js +13 -7
  154. package/src/htmx/browserRuntime.js +2 -93
  155. package/src/htmx/metadata.js +19 -7
  156. package/src/validation/ActionInputError.js +12 -0
  157. package/src/validation/SchemaValidator.js +38 -0
  158. package/src/ws/AdmissionPolicy.js +24 -23
  159. package/src/ws/BaseSocketServer.js +53 -38
  160. package/src/ws/ContractValidationError.js +12 -0
  161. package/src/ws/HeartbeatMonitor.js +26 -8
  162. package/src/ws/ProtocolPolicy.js +1 -1
  163. package/src/ws/RoomAccess.js +82 -0
  164. package/src/ws/RoomRegistry.js +56 -6
  165. package/src/ws/RouteRuntime.js +56 -10
  166. package/src/ws/SocketContract.js +112 -0
  167. package/src/ws/SocketRoute.js +18 -0
  168. package/src/ws/protocol-schema.json +6 -1
  169. package/examples/live-html/chatroom.ts +0 -217
@@ -0,0 +1,167 @@
1
+ import { action, component, page, start, state, type ActionInput } from 'redweb';
2
+ import { z } from 'zod';
3
+
4
+ const MAX_VISIBLE_MEMBERS = 100;
5
+ const visibleText = (maximum: number) => z.string()
6
+ .transform(value => value.normalize('NFKC').trim())
7
+ .pipe(z.string().min(1).max(maximum).regex(/^[^\p{Cc}\p{Cf}]+$/u));
8
+ export const chatInputs = {
9
+ join: z.object({ name: visibleText(40) }).strict(),
10
+ send: z.object({ message: visibleText(500) }).strict(),
11
+ };
12
+
13
+ interface StoredMessage { id: number; sender: string; text: string; }
14
+ interface RoomParticipant {
15
+ readonly displayName: string;
16
+ updateMessages(messages: readonly StoredMessage[]): void;
17
+ updatePresence(members: readonly string[]): void;
18
+ }
19
+
20
+ class ChatRoom {
21
+ private history: readonly StoredMessage[] = [];
22
+ private nextMessageId = 0;
23
+ private readonly participants = new Set<RoomParticipant>();
24
+ private readonly online = new Set<RoomParticipant>();
25
+
26
+ join(participant: RoomParticipant) {
27
+ const name = participant.displayName.toLocaleLowerCase();
28
+ if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) return false;
29
+ this.participants.add(participant);
30
+ this.online.add(participant);
31
+ participant.updateMessages(this.history);
32
+ this.publishPresence();
33
+ return true;
34
+ }
35
+
36
+ disconnect(participant: RoomParticipant) {
37
+ if (this.online.delete(participant)) this.publishPresence();
38
+ }
39
+
40
+ leave(participant: RoomParticipant) {
41
+ this.online.delete(participant);
42
+ if (this.participants.delete(participant)) this.publishPresence();
43
+ }
44
+
45
+ send(participant: RoomParticipant, text: string) {
46
+ if (!this.online.has(participant)) return false;
47
+ this.history = [...this.history, { id: ++this.nextMessageId, sender: participant.displayName, text }].slice(-100);
48
+ for (const member of this.participants) member.updateMessages(this.history);
49
+ return true;
50
+ }
51
+
52
+ private publishPresence() {
53
+ const members = [...this.online].map(participant => participant.displayName);
54
+ for (const participant of this.participants) participant.updatePresence(members);
55
+ }
56
+ }
57
+
58
+ @component()
59
+ export class ChatroomComponent implements RoomParticipant {
60
+ @state() displayName = '';
61
+ @state() feedback = '';
62
+ @state() messages: readonly StoredMessage[] = [];
63
+ @state() members: readonly string[] = [];
64
+
65
+ constructor(private readonly room: ChatRoom) {}
66
+
67
+ connected() { if (this.displayName) this.room.join(this); }
68
+ disconnected() { this.room.disconnect(this); }
69
+ disposed() { this.room.leave(this); }
70
+
71
+ @action({ input: chatInputs.join })
72
+ join({ name }: ActionInput<typeof chatInputs.join>) {
73
+ if (this.displayName) return false;
74
+ this.displayName = name;
75
+ if (!this.room.join(this)) {
76
+ this.displayName = '';
77
+ this.feedback = 'That display name is already in use.';
78
+ return false;
79
+ }
80
+ this.feedback = '';
81
+ return true;
82
+ }
83
+
84
+ @action({ input: chatInputs.send })
85
+ send({ message }: ActionInput<typeof chatInputs.send>) {
86
+ return this.room.send(this, message);
87
+ }
88
+
89
+ @action()
90
+ leave() {
91
+ this.room.leave(this);
92
+ this.displayName = '';
93
+ this.feedback = '';
94
+ this.messages = [];
95
+ this.members = [];
96
+ }
97
+
98
+ updateMessages(messages: readonly StoredMessage[]) { this.messages = messages; }
99
+ updatePresence(members: readonly string[]) { this.members = members; }
100
+
101
+ render() {
102
+ return <section class="chatroom">{this.displayName ? this.roomScreen() : this.joinScreen()}</section>;
103
+ }
104
+
105
+ private joinScreen() {
106
+ return (
107
+ <section class="join-panel">
108
+ <p class="eyebrow">Live room</p>
109
+ <h1>Join the chatroom</h1>
110
+ <p>Choose a name once, then chat in realtime with everyone currently in the room.</p>
111
+ {this.feedback && <p class="form-error" role="alert">{this.feedback}</p>}
112
+ <form rw-submit="join" class="join-form">
113
+ <label for="display-name">Display name</label>
114
+ <div class="input-row">
115
+ <input id="display-name" name="name" maxlength="40" autocomplete="nickname" required autofocus />
116
+ <button type="submit">Join room</button>
117
+ </div>
118
+ </form>
119
+ </section>
120
+ );
121
+ }
122
+
123
+ private roomScreen() {
124
+ const remaining = this.members.length - MAX_VISIBLE_MEMBERS;
125
+ return (
126
+ <div class="room-layout">
127
+ <section class="conversation">
128
+ <header class="room-header">
129
+ <div><p class="eyebrow">Connected as</p><h1>{this.displayName}</h1></div>
130
+ <button type="button" class="quiet-button" rw-click="leave">Leave</button>
131
+ </header>
132
+ <ol class="message-list" aria-live="polite">
133
+ {this.messages.length ? this.messages.map(entry => (
134
+ <li key={entry.id}><strong>{entry.sender}</strong><p>{entry.text}</p></li>
135
+ )) : <li class="empty-message">No messages yet. Say hello.</li>}
136
+ </ol>
137
+ <form rw-submit="send" class="composer">
138
+ <label class="sr-only" for="chat-message">Message</label>
139
+ <input id="chat-message" name="message" maxlength="500" autocomplete="off" placeholder="Message the room…" required autofocus />
140
+ <button type="submit">Send</button>
141
+ </form>
142
+ </section>
143
+ <aside class="presence" aria-label="People in the room">
144
+ <p class="eyebrow">Online · {this.members.length}</p>
145
+ <ul>
146
+ {this.members.slice(0, MAX_VISIBLE_MEMBERS).map(member => <li key={member}>{member}</li>)}
147
+ {remaining > 0 && <li class="more-members">+{remaining} more</li>}
148
+ </ul>
149
+ </aside>
150
+ </div>
151
+ );
152
+ }
153
+ }
154
+
155
+ export function createChatroomPage() {
156
+ const room = new ChatRoom();
157
+
158
+ @page('/', { css: 'chatroom.css' })
159
+ class ChatroomPage {
160
+ chat = new ChatroomComponent(room);
161
+ render() { return <main>{this.chat}</main>; }
162
+ }
163
+
164
+ return ChatroomPage;
165
+ }
166
+
167
+ if (require.main === module) start(createChatroomPage(), { port: 8080 });
@@ -67,7 +67,7 @@ let JsxPage = (() => {
67
67
  this.count += 1;
68
68
  }
69
69
  render() {
70
- return ((0, jsx_runtime_1.jsxs)("main", { class: "page-shell", children: [(0, jsx_runtime_1.jsx)("h1", { children: "Redweb JSX" }), (0, jsx_runtime_1.jsxs)(Card, { title: "Server rendered", children: [(0, jsx_runtime_1.jsx)("p", { children: "Plain TSX, escaped by default, with no browser framework." }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "rw-click": "increment", children: ["Count ", (0, jsx_runtime_1.jsx)("output", { "data-rw-state": "count", children: this.count })] })] })] }));
70
+ return ((0, jsx_runtime_1.jsxs)("main", { class: "page-shell", children: [(0, jsx_runtime_1.jsx)("h1", { children: "Redweb JSX" }), (0, jsx_runtime_1.jsxs)(Card, { title: "Server rendered", children: [(0, jsx_runtime_1.jsx)("p", { children: "Plain TSX, escaped by default, with no browser framework." }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "rw-click": "increment", children: ["Count ", (0, jsx_runtime_1.jsx)("output", { children: this.count })] })] })] }));
71
71
  }
72
72
  constructor() {
73
73
  super(...arguments);
@@ -30,7 +30,7 @@ export class JsxPage extends LivePage {
30
30
  <Card title="Server rendered">
31
31
  <p>Plain TSX, escaped by default, with no browser framework.</p>
32
32
  <button type="button" rw-click="increment">
33
- Count <output data-rw-state="count">{this.count}</output>
33
+ Count <output>{this.count}</output>
34
34
  </button>
35
35
  </Card>
36
36
  </main>
@@ -1,13 +1,14 @@
1
- {
2
- "extends": "../../config/tsconfig.json",
3
- "compilerOptions": {
4
- "module": "CommonJS",
5
- "moduleResolution": "Node",
6
- "baseUrl": "../..",
1
+ {
2
+ "extends": "../../config/tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "esModuleInterop": true,
7
+ "baseUrl": "../..",
7
8
  "paths": {
8
9
  "redweb": ["."],
9
10
  "redweb/*": ["*"]
10
11
  }
11
12
  },
12
- "files": ["counter.ts", "chatroom.ts", "cards.ts", "components.ts", "jsx-page.tsx"]
13
+ "files": ["counter.ts", "chatroom.tsx", "cards.ts", "components.ts", "jsx-page.tsx"]
13
14
  }
package/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  declare module 'redweb' {
2
- import { Application } from 'express';
2
+ export { defineSocketContract } from 'redweb/contract';
3
+ export type { SocketContract, ContractClient, SocketSchema, ContractInput, ContractOutput, ContractMessage } from 'redweb/contract';
4
+ import { Application, RequestHandler } from 'express';
3
5
  import { CorsOptions } from 'cors';
4
6
  import { Server as NodeHttpServer } from 'http';
5
7
  import { Server as NodeHttpsServer } from 'https';
@@ -15,7 +17,7 @@ declare module 'redweb' {
15
17
  port?: number;
16
18
  bind?: string;
17
19
  publicPaths?: string[];
18
- services?: Array<{ serviceName: string; method: string; function: Function }>;
20
+ services?: Array<{ serviceName: string; method: string; function: RequestHandler }>;
19
21
  listen?: boolean;
20
22
  listenCallback?: () => void;
21
23
  encoding?: RedWebEncoding;
@@ -35,6 +37,8 @@ declare module 'redweb' {
35
37
  broadcast(data: unknown): number;
36
38
  context?: RedWebConnectionContext;
37
39
  joinRoom?(roomId: string): boolean;
40
+ /** Bounded permission check followed by atomic membership insertion. */
41
+ enterRoom?(roomId: string): Promise<boolean>;
38
42
  leaveRoom?(roomId: string): boolean;
39
43
  roomBroadcast?(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
40
44
  createSession?(sessionId: string, data: unknown): boolean;
@@ -45,13 +49,12 @@ declare module 'redweb' {
45
49
  sendBinaryEvent?(value: unknown): Promise<boolean>;
46
50
  };
47
51
 
48
- export interface RedWebConnectionContext {
49
- connectionId: string;
50
- principal: unknown;
52
+ export interface RedWebConnectionContext extends RequestContext {
53
+ readonly connectionId: string;
54
+ readonly principal: unknown;
51
55
  session: unknown | null;
52
56
  metadata: Record<string, unknown>;
53
- signal?: AbortSignal;
54
- protocol?: Readonly<{ version: string }>;
57
+ readonly protocol?: Readonly<{ version: string }>;
55
58
  }
56
59
 
57
60
  export interface AdmissionContext {
@@ -98,12 +101,18 @@ declare module 'redweb' {
98
101
  timeoutMs: number;
99
102
  }
100
103
 
101
- export interface RoomOptions {
104
+ export type RoomOptions = {
102
105
  maxRooms?: number;
103
106
  maxMembersPerRoom?: number;
104
107
  maxRoomsPerConnection?: number;
105
108
  maxRoomIdLength?: number;
106
- }
109
+ } & ({ authorize?: undefined; authorizationTimeoutMs?: never; maxPendingAuthorizations?: never; maxPendingPerConnection?: never } | {
110
+ /** Grants subscription until explicit leave/disconnect; not per-message receive authorization. */
111
+ authorize: (context: Readonly<RedWebConnectionContext>, roomId: string) => boolean | Promise<boolean>;
112
+ authorizationTimeoutMs?: number;
113
+ maxPendingAuthorizations?: number;
114
+ maxPendingPerConnection?: number;
115
+ });
107
116
 
108
117
  export interface SessionOptions {
109
118
  ttlMs?: number;
@@ -164,7 +173,7 @@ declare module 'redweb' {
164
173
  }
165
174
 
166
175
  export interface ProtocolOptions {
167
- versions: string[];
176
+ versions: readonly string[];
168
177
  required?: boolean;
169
178
  queryParameter?: string;
170
179
  header?: string;
@@ -173,7 +182,86 @@ declare module 'redweb' {
173
182
 
174
183
  /** ─────────────────── SOCKET SERVER ─────────────────── */
175
184
 
185
+ export interface DevelopmentOptions {
186
+ /** Explicit local-only inspection; rejected when NODE_ENV is production. */
187
+ inspect?: boolean;
188
+ }
189
+
190
+ export interface LiveDevelopmentOptions extends DevelopmentOptions {
191
+ /** Loopback-only browser refresh. False overrides REDWEB_DEV_REFRESH=1. */
192
+ refresh?: boolean;
193
+ }
194
+
195
+ export interface InspectionList<T> {
196
+ readonly items: readonly T[];
197
+ readonly total: number;
198
+ readonly truncated: boolean;
199
+ }
200
+ export type InspectionSection<T> = { readonly available: false } | ({ readonly available: true } & T);
201
+ export interface InspectionMembers {
202
+ readonly className: string;
203
+ readonly actions: InspectionList<string>;
204
+ readonly states: InspectionList<string>;
205
+ }
206
+ export interface InspectionPage extends InspectionMembers {
207
+ readonly path: string;
208
+ readonly live: boolean;
209
+ readonly shared: boolean;
210
+ readonly instanceMetadata: 'observed' | 'unobserved';
211
+ readonly instances: InspectionList<{
212
+ readonly id: number;
213
+ readonly disposed: boolean;
214
+ readonly components: InspectionList<InspectionMembers & { readonly id: string }>;
215
+ }>;
216
+ }
217
+ export interface InspectionSession {
218
+ /** Inspector-local IDs; never page tokens, credentials or socket IDs. */
219
+ readonly render: number;
220
+ readonly instance: number;
221
+ readonly route: string;
222
+ readonly status: 'connected' | 'detaching' | 'pending' | 'retained';
223
+ readonly reactive: boolean;
224
+ }
225
+ export interface InspectionEvent {
226
+ readonly sequence: number;
227
+ readonly render: number;
228
+ readonly route: string;
229
+ readonly kind: 'state-invalidated' | 'flush-started' | 'flush-completed' | 'flush-superseded' | 'flush-failed';
230
+ readonly state?: string;
231
+ readonly component?: string;
232
+ readonly affectedOwners?: InspectionList<string>;
233
+ readonly snapshot?: boolean;
234
+ readonly dirtyOwners?: InspectionList<string>;
235
+ readonly durationMs?: number;
236
+ }
237
+ export interface DevelopmentSnapshot {
238
+ readonly schemaVersion: 1;
239
+ readonly mode: 'development';
240
+ readonly pages: InspectionSection<{
241
+ readonly registrations: InspectionList<InspectionPage>;
242
+ readonly sessions: InspectionList<InspectionSession>;
243
+ readonly closing?: boolean;
244
+ readonly rendering?: number;
245
+ readonly connections?: Readonly<Record<InspectionSession['status'], number>>;
246
+ }>;
247
+ readonly sockets: InspectionSection<{
248
+ readonly routes: InspectionList<{
249
+ readonly path: string;
250
+ readonly handlers: InspectionList<string>;
251
+ readonly registeredConnections: number;
252
+ readonly draining: boolean;
253
+ readonly rooms: number;
254
+ readonly sessions: number;
255
+ }>;
256
+ readonly pendingUpgrades: number;
257
+ readonly draining: boolean;
258
+ }>;
259
+ /** Flush completion is not a network delivery guarantee. */
260
+ readonly history: InspectionList<InspectionEvent> & { readonly limit: number };
261
+ }
262
+
176
263
  export interface SocketServerOptions {
264
+ development?: DevelopmentOptions;
177
265
  server?: NodeHttpServer;
178
266
  port?: number;
179
267
  bind?: string;
@@ -306,6 +394,7 @@ declare module 'redweb' {
306
394
 
307
395
  addRoute(route: new () => SocketRoute): SocketRoute;
308
396
  isReady(): boolean;
397
+ inspect(): DevelopmentSnapshot | null;
309
398
  beginDrain(): boolean;
310
399
  shutdown(): Promise<void>;
311
400
  }
@@ -317,11 +406,13 @@ declare module 'redweb' {
317
406
  export class RoomRegistry {
318
407
  constructor(options?: RoomOptions);
319
408
  join(roomId: string, socket: RedWebSocket): boolean;
409
+ enter(roomId: string, socket: RedWebSocket): Promise<boolean>;
320
410
  leave(roomId: string, socket: RedWebSocket): boolean;
321
411
  leaveAll(socket: RedWebSocket): number;
322
412
  members(roomId: string): RedWebSocket[];
323
413
  has(roomId: string, socket: RedWebSocket): boolean;
324
414
  broadcast(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
415
+ broadcastFrom(socket: RedWebSocket, roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
325
416
  clear(): void;
326
417
  close(): boolean;
327
418
  readonly size: number;
@@ -421,30 +512,33 @@ declare module 'redweb' {
421
512
  readonly [htmlUrlBrand]: true;
422
513
  }
423
514
 
424
- export interface LivePageRequest {
515
+ export interface RedWebRequest {
425
516
  readonly path: string;
426
517
  readonly url: string;
427
518
  readonly method: string;
428
- readonly headers: import('http').IncomingHttpHeaders;
519
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>;
429
520
  readonly params: Readonly<Record<string, string>>;
430
521
  readonly query: Readonly<Record<string, unknown>>;
431
522
  readonly body: unknown;
432
523
  get(name: string): string | undefined;
433
524
  }
434
525
 
435
- export interface LivePageRequestContext {
436
- request: LivePageRequest;
437
- params: Readonly<Record<string, string>>;
438
- query: Readonly<Record<string, unknown>>;
439
- body: unknown;
440
- principal?: string | number | bigint | boolean;
441
- signal: AbortSignal;
526
+ export interface LivePageRequest extends RedWebRequest {}
527
+
528
+ export interface RequestContext<Principal = unknown> {
529
+ readonly request: RedWebRequest;
530
+ readonly params: Readonly<Record<string, string>>;
531
+ readonly query: Readonly<Record<string, unknown>>;
532
+ readonly body: unknown;
533
+ readonly principal?: Principal;
534
+ readonly signal: AbortSignal;
442
535
  }
443
536
 
444
- export interface LivePageConnectionContext {
537
+ export interface LivePageRequestContext extends RequestContext<string | number | bigint | boolean> {}
538
+
539
+ /** The original page request/identity is retained across normal reconnects. */
540
+ export interface LivePageConnectionContext extends LivePageRequestContext {
445
541
  socket: RedWebSocket;
446
- signal?: AbortSignal;
447
- principal?: string | number | bigint | boolean;
448
542
  }
449
543
 
450
544
  export abstract class LivePage {
@@ -452,21 +546,27 @@ declare module 'redweb' {
452
546
  loading?(context: LivePageRequestContext): void | Promise<void>;
453
547
  render?(context: LivePageRequestContext): string | HtmlFragment | Promise<string | HtmlFragment>;
454
548
  connected?(context: LivePageConnectionContext): void | Promise<void>;
455
- disconnected?(context: { socket: RedWebSocket }): void | Promise<void>;
549
+ disconnected?(context: LivePageConnectionContext): void | Promise<void>;
456
550
  disposed?(): void | Promise<void>;
457
551
  dispose(): Promise<boolean>;
458
552
  }
459
553
 
460
- export interface PageOptions {
554
+ export type PageOptions = {
461
555
  template?: string;
462
556
  css?: string | readonly string[];
463
- scope?: 'connection' | 'shared';
464
- shared?: boolean;
465
557
  live?: boolean;
466
558
  head?: PageHead;
467
559
  cache?: PageCache;
468
560
  layout?: PageLayout;
469
- }
561
+ } & ({
562
+ scope?: 'connection' | 'shared'; shared?: boolean;
563
+ authorize?: undefined; authorizationTimeoutMs?: never;
564
+ } | {
565
+ scope?: 'connection'; shared?: false;
566
+ /** Checked before construction/loading, on connection, and before actions/state writes. */
567
+ authorize: (context: LivePageRequestContext) => boolean | Promise<boolean>;
568
+ authorizationTimeoutMs?: number;
569
+ });
470
570
 
471
571
  export type PageLayout = (content: HtmlFragment, context: LivePageRequestContext) => HtmlFragment;
472
572
 
@@ -502,6 +602,17 @@ declare module 'redweb' {
502
602
  ): Value;
503
603
  }
504
604
 
605
+ /** The validated (possibly transformed) value passed as an action's first argument. */
606
+ export type ActionInput<Schema extends import('redweb/contract').SocketSchema> = import('redweb/contract').ContractOutput<Schema>;
607
+
608
+ export interface ValidatedActionDecorator<Input> {
609
+ <Value extends (input: Input, context: LivePageConnectionContext) => any>(target: object, propertyKey: string,
610
+ descriptor: TypedPropertyDescriptor<Value>): void;
611
+ <This, Value extends (this: This, input: Input, context: LivePageConnectionContext) => any>(
612
+ value: Value, context: ClassMethodDecoratorContext<This, Value>
613
+ ): Value;
614
+ }
615
+
505
616
  export interface LiveViewDecorator {
506
617
  (target: object, propertyKey: string, descriptor: PropertyDescriptor): void | PropertyDescriptor;
507
618
  <This, Value extends (this: This, item: any, index: number) => HtmlFragment>(
@@ -516,6 +627,18 @@ declare module 'redweb' {
516
627
  (properties: Props) => HtmlFragment;
517
628
  export function state(options?: StateOptions): LiveStateDecorator;
518
629
  export function action(): LiveActionDecorator;
630
+ export interface ActionAuthorization<Input> {
631
+ authorize: (context: LivePageConnectionContext, input: Input) => boolean | Promise<boolean>;
632
+ /** Bounds permission checks, not application execution; defaults to 5000ms. */
633
+ authorizationTimeoutMs?: number;
634
+ }
635
+ export function action<Schema extends import('redweb/contract').SocketSchema>(options: {
636
+ input: Schema;
637
+ /** Bounds input validation, not application execution; defaults to 5000ms. */
638
+ validationTimeoutMs?: number;
639
+ } & (ActionAuthorization<ActionInput<Schema>> | { authorize?: undefined; authorizationTimeoutMs?: never })): ValidatedActionDecorator<ActionInput<Schema>>;
640
+ /** Authorized actions use a fixed (input, context) shape, including buttons without a payload. */
641
+ export function action(options: ActionAuthorization<unknown>): ValidatedActionDecorator<unknown>;
519
642
  export function view(stateName: string): LiveViewDecorator;
520
643
  export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlFragment;
521
644
  export function attribute(value: string | number | bigint | boolean): HtmlAttribute;
@@ -529,7 +652,8 @@ declare module 'redweb' {
529
652
 
530
653
  export type LivePageClass = new () => object;
531
654
 
532
- export interface LiveHtmlServerOptions extends Omit<RedWebOptions, 'enableHtmxRendering'> {
655
+ export interface LiveHtmlServerBaseOptions extends Omit<RedWebOptions, 'enableHtmxRendering'> {
656
+ development?: LiveDevelopmentOptions;
533
657
  pages: readonly LivePageClass[];
534
658
  templateRoot?: string;
535
659
  livePaths?: {
@@ -541,26 +665,38 @@ declare module 'redweb' {
541
665
  sessionTtlMs?: number;
542
666
  maxSessions?: number;
543
667
  maxConcurrentRenders?: number;
668
+ /** Phase-local render/route and final owned-HTTP cleanup bound; defaults to 1000ms, not a total application deadline. */
544
669
  shutdownTimeoutMs?: number;
545
670
  heartbeat?: HeartbeatOptions;
546
- authenticate?(request: import('http').IncomingMessage | import('express').Request):
547
- string | number | bigint | boolean | false | null | undefined |
548
- Promise<string | number | bigint | boolean | false | null | undefined>;
549
671
  origins?: string[] | ((origin: string | undefined, request: import('http').IncomingMessage) => boolean | Promise<boolean>);
550
672
  }
551
673
 
674
+ export type LiveHtmlAuthentication = {
675
+ authenticate(request: import('http').IncomingMessage | import('express').Request):
676
+ string | number | bigint | boolean | null | undefined |
677
+ Promise<string | number | bigint | boolean | null | undefined>;
678
+ /** Bounds identity lookup, not external application work; defaults to 5000ms. */
679
+ authenticationTimeoutMs?: number;
680
+ } | { authenticate?: undefined; authenticationTimeoutMs?: never };
681
+ export type LiveHtmlServerOptions = LiveHtmlServerBaseOptions & LiveHtmlAuthentication;
682
+ export type LiveHtmlStartOptions = Omit<LiveHtmlServerBaseOptions, 'pages'> & LiveHtmlAuthentication;
683
+
552
684
  export class LiveHtmlServer {
553
685
  app: Application;
554
686
  server: NodeHttpServer | NodeHttpsServer;
555
687
  http: HttpServer | HttpsServer;
556
688
  sockets: SocketServer | null;
557
689
  constructor(options: LiveHtmlServerOptions);
690
+ /** Revoke matching in-process sessions/renders; credential invalidation remains application-owned. */
691
+ revoke(principal: string | number | bigint | true): Promise<number>;
692
+ /** Local metadata only; null unless development inspection was explicitly enabled. */
693
+ inspect(): DevelopmentSnapshot | null;
558
694
  shutdown(): Promise<void>;
559
695
  }
560
696
 
561
697
  export function start(
562
698
  pageOrPages: LivePageClass | readonly LivePageClass[],
563
- options?: Omit<LiveHtmlServerOptions, 'pages'>
699
+ options?: LiveHtmlStartOptions
564
700
  ): LiveHtmlServer;
565
701
 
566
702
  export interface StaticExportOptions {
@@ -587,9 +723,7 @@ declare module 'redweb' {
587
723
  layout?: PageLayout;
588
724
  }
589
725
 
590
- export interface SitePageOptions extends Omit<PageOptions, 'live'> {
591
- live?: false;
592
- }
726
+ export type SitePageOptions = PageOptions & { live?: false };
593
727
 
594
728
  export interface SiteExportOptions extends StaticExportOptions {
595
729
  publicDir?: string;
@@ -641,14 +775,5 @@ declare module 'redweb' {
641
775
  export const HTTP_OPTIONS: RedWebOptions;
642
776
  export const SOCKET_OPTIONS: SocketServerOptions;
643
777
 
644
- export const ERROR_CODES: Readonly<{
645
- INVALID_MESSAGE: 'INVALID_MESSAGE';
646
- UNKNOWN_HANDLER: 'UNKNOWN_HANDLER';
647
- HANDLER_FAILED: 'HANDLER_FAILED';
648
- BINARY_UNSUPPORTED: 'BINARY_UNSUPPORTED';
649
- RATE_LIMITED: 'RATE_LIMITED';
650
- QUEUE_FULL: 'QUEUE_FULL';
651
- CAPACITY_REACHED: 'CAPACITY_REACHED';
652
- INITIALIZATION_FAILED: 'INITIALIZATION_FAILED';
653
- }>;
778
+ export const ERROR_CODES: typeof import('redweb/client').ERROR_CODES;
654
779
  }
package/index.js CHANGED
@@ -14,6 +14,7 @@ const {
14
14
  ERROR_CODES,
15
15
  } = require('./src/ws');
16
16
  const { BaseHandler } = require('./src/ws/BaseHandler');
17
+ const { defineSocketContract } = require('./contract');
17
18
  const HttpServer = require('./src/http/HttpServer');
18
19
  const HttpsServer = require('./src/http/HttpsServer');
19
20
  const { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view } = require('./src/htmx');
@@ -24,6 +25,7 @@ module.exports = {
24
25
  SocketServer,
25
26
  SecureSocketServer,
26
27
  BaseHandler,
28
+ defineSocketContract,
27
29
  SocketRoute,
28
30
  SocketService,
29
31
  FixedStepService,
@@ -2,8 +2,8 @@
2
2
 
3
3
  const { Fragment, createElement } = require('./src/htmx/Jsx');
4
4
 
5
- function jsxDEV(type, properties) {
6
- return createElement(type, properties);
5
+ function jsxDEV(type, properties, key) {
6
+ return createElement(type, properties, key);
7
7
  }
8
8
 
9
9
  module.exports = { Fragment, jsxDEV };
package/jsx-runtime.d.ts CHANGED
@@ -1,6 +1,11 @@
1
- import type { HtmlFragment } from 'redweb';
1
+ import type { HtmlFragment, LivePageRequestContext } from 'redweb';
2
2
 
3
- export type Child = HtmlFragment | string | number | bigint | boolean | null | undefined | readonly Child[];
3
+ /** A synchronous @component() instance owned by a page/component field. Ownership is checked at runtime. */
4
+ export interface ServerComponent {
5
+ render(context: LivePageRequestContext): string | HtmlFragment | readonly HtmlFragment[];
6
+ }
7
+
8
+ export type Child = HtmlFragment | ServerComponent | string | number | bigint | boolean | null | undefined | readonly Child[];
4
9
 
5
10
  export interface IntrinsicAttributes {
6
11
  key?: string | number;