@carno.js/live 1.8.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.
Files changed (199) hide show
  1. package/LICENSE +21 -0
  2. package/dist/LiveEngine.d.ts +89 -0
  3. package/dist/LiveEngine.js +520 -0
  4. package/dist/LivePlugin.d.ts +55 -0
  5. package/dist/LivePlugin.js +165 -0
  6. package/dist/LiveService.d.ts +32 -0
  7. package/dist/LiveService.js +51 -0
  8. package/dist/auth/authorizer.d.ts +33 -0
  9. package/dist/auth/authorizer.js +32 -0
  10. package/dist/bus/InProcessBus.d.ts +8 -0
  11. package/dist/bus/InProcessBus.js +29 -0
  12. package/dist/bus/InvalidationBus.d.ts +15 -0
  13. package/dist/bus/InvalidationBus.js +2 -0
  14. package/dist/bus/PgNotifyBus.d.ts +55 -0
  15. package/dist/bus/PgNotifyBus.js +138 -0
  16. package/dist/client/angular.d.ts +19 -0
  17. package/dist/client/angular.js +71 -0
  18. package/dist/client/core.d.ts +111 -0
  19. package/dist/client/core.js +337 -0
  20. package/dist/client/hydrate.d.ts +19 -0
  21. package/dist/client/hydrate.js +65 -0
  22. package/dist/client/optimistic.d.ts +17 -0
  23. package/dist/client/optimistic.js +2 -0
  24. package/dist/client/react.d.ts +22 -0
  25. package/dist/client/react.js +69 -0
  26. package/dist/client/transport.d.ts +133 -0
  27. package/dist/client/transport.js +431 -0
  28. package/dist/client/vanilla.d.ts +54 -0
  29. package/dist/client/vanilla.js +111 -0
  30. package/dist/client/vue.d.ts +12 -0
  31. package/dist/client/vue.js +35 -0
  32. package/dist/config.d.ts +32 -0
  33. package/dist/config.js +21 -0
  34. package/dist/decorators/Live.d.ts +4 -0
  35. package/dist/decorators/Live.js +17 -0
  36. package/dist/emitters/AppEmitter.d.ts +20 -0
  37. package/dist/emitters/AppEmitter.js +59 -0
  38. package/dist/emitters/pg-listener.d.ts +50 -0
  39. package/dist/emitters/pg-listener.js +120 -0
  40. package/dist/emitters/pg-notify-emitter.d.ts +59 -0
  41. package/dist/emitters/pg-notify-emitter.js +105 -0
  42. package/dist/emitters/pg-trigger-sql.d.ts +27 -0
  43. package/dist/emitters/pg-trigger-sql.js +107 -0
  44. package/dist/emitters/statement-keys.d.ts +16 -0
  45. package/dist/emitters/statement-keys.js +182 -0
  46. package/dist/graph/DependencyGraph.d.ts +27 -0
  47. package/dist/graph/DependencyGraph.js +118 -0
  48. package/dist/graph/SubscriptionRegistry.d.ts +22 -0
  49. package/dist/graph/SubscriptionRegistry.js +92 -0
  50. package/dist/graph/dep-key.d.ts +14 -0
  51. package/dist/graph/dep-key.js +37 -0
  52. package/dist/graph/types.d.ts +13 -0
  53. package/dist/graph/types.js +2 -0
  54. package/dist/http/etag.d.ts +41 -0
  55. package/dist/http/etag.js +109 -0
  56. package/dist/index.d.ts +56 -0
  57. package/dist/index.js +111 -0
  58. package/dist/metadata.d.ts +16 -0
  59. package/dist/metadata.js +4 -0
  60. package/dist/observability.d.ts +25 -0
  61. package/dist/observability.js +44 -0
  62. package/dist/patch/PatchEngine.d.ts +20 -0
  63. package/dist/patch/PatchEngine.js +180 -0
  64. package/dist/patch/types.d.ts +33 -0
  65. package/dist/patch/types.js +2 -0
  66. package/dist/resource/ResourceRegistry.d.ts +32 -0
  67. package/dist/resource/ResourceRegistry.js +126 -0
  68. package/dist/resource/dependency-context.d.ts +25 -0
  69. package/dist/resource/dependency-context.js +45 -0
  70. package/dist/resource/instance-id.d.ts +17 -0
  71. package/dist/resource/instance-id.js +61 -0
  72. package/dist/resource/prefetch.d.ts +20 -0
  73. package/dist/resource/prefetch.js +31 -0
  74. package/dist/resource/route-executor.d.ts +13 -0
  75. package/dist/resource/route-executor.js +86 -0
  76. package/dist/resource/types.d.ts +26 -0
  77. package/dist/resource/types.js +2 -0
  78. package/dist/runtime.d.ts +41 -0
  79. package/dist/runtime.js +47 -0
  80. package/dist/shared/canonical.d.ts +14 -0
  81. package/dist/shared/canonical.js +60 -0
  82. package/dist/shared/descriptor.d.ts +44 -0
  83. package/dist/shared/descriptor.js +19 -0
  84. package/dist/shared/hash.d.ts +12 -0
  85. package/dist/shared/hash.js +29 -0
  86. package/dist/shared/inputs.d.ts +19 -0
  87. package/dist/shared/inputs.js +2 -0
  88. package/dist/shared/protocol.d.ts +83 -0
  89. package/dist/shared/protocol.js +9 -0
  90. package/dist/transport/FanTransport.d.ts +18 -0
  91. package/dist/transport/FanTransport.js +29 -0
  92. package/dist/transport/LiveGateway.d.ts +16 -0
  93. package/dist/transport/LiveGateway.js +126 -0
  94. package/dist/transport/SocketTransport.d.ts +18 -0
  95. package/dist/transport/SocketTransport.js +40 -0
  96. package/dist/transport/SseTransport.d.ts +30 -0
  97. package/dist/transport/SseTransport.js +92 -0
  98. package/dist/transport/scope-resolver.d.ts +20 -0
  99. package/dist/transport/scope-resolver.js +17 -0
  100. package/dist/transport/sse-routes.d.ts +24 -0
  101. package/dist/transport/sse-routes.js +78 -0
  102. package/package.json +92 -0
  103. package/src/LiveEngine.ts +730 -0
  104. package/src/LivePlugin.ts +253 -0
  105. package/src/LiveService.ts +47 -0
  106. package/src/auth/authorizer.ts +56 -0
  107. package/src/bus/InProcessBus.ts +28 -0
  108. package/src/bus/InvalidationBus.ts +17 -0
  109. package/src/bus/PgNotifyBus.ts +188 -0
  110. package/src/client/angular.ts +115 -0
  111. package/src/client/core.ts +492 -0
  112. package/src/client/hydrate.ts +80 -0
  113. package/src/client/optimistic.ts +19 -0
  114. package/src/client/react.ts +113 -0
  115. package/src/client/transport.ts +568 -0
  116. package/src/client/vanilla.ts +165 -0
  117. package/src/client/vue.ts +67 -0
  118. package/src/config.ts +49 -0
  119. package/src/decorators/Live.ts +16 -0
  120. package/src/emitters/AppEmitter.ts +69 -0
  121. package/src/emitters/pg-listener.ts +171 -0
  122. package/src/emitters/pg-notify-emitter.ts +143 -0
  123. package/src/emitters/pg-trigger-sql.ts +111 -0
  124. package/src/emitters/statement-keys.ts +229 -0
  125. package/src/graph/DependencyGraph.ts +147 -0
  126. package/src/graph/SubscriptionRegistry.ts +109 -0
  127. package/src/graph/dep-key.ts +41 -0
  128. package/src/graph/types.ts +15 -0
  129. package/src/http/etag.ts +144 -0
  130. package/src/index.ts +81 -0
  131. package/src/metadata.ts +21 -0
  132. package/src/observability.ts +54 -0
  133. package/src/patch/PatchEngine.ts +235 -0
  134. package/src/patch/types.ts +39 -0
  135. package/src/resource/ResourceRegistry.ts +178 -0
  136. package/src/resource/dependency-context.ts +48 -0
  137. package/src/resource/instance-id.ts +65 -0
  138. package/src/resource/prefetch.ts +49 -0
  139. package/src/resource/route-executor.ts +134 -0
  140. package/src/resource/types.ts +35 -0
  141. package/src/runtime.ts +69 -0
  142. package/src/shared/canonical.ts +63 -0
  143. package/src/shared/descriptor.ts +44 -0
  144. package/src/shared/hash.ts +31 -0
  145. package/src/shared/inputs.ts +20 -0
  146. package/src/shared/protocol.ts +96 -0
  147. package/src/transport/FanTransport.ts +34 -0
  148. package/src/transport/LiveGateway.ts +124 -0
  149. package/src/transport/SocketTransport.ts +43 -0
  150. package/src/transport/SseTransport.ts +112 -0
  151. package/src/transport/scope-resolver.ts +25 -0
  152. package/src/transport/sse-routes.ts +94 -0
  153. package/test/acceptance-fase-2.test.ts +248 -0
  154. package/test/acceptance-fase-3.test.ts +216 -0
  155. package/test/acceptance.test.ts +306 -0
  156. package/test/angular-adapter.test.ts +151 -0
  157. package/test/app-emitter.test.ts +112 -0
  158. package/test/authorization.test.ts +175 -0
  159. package/test/client-core.test.ts +287 -0
  160. package/test/client-transport.test.ts +154 -0
  161. package/test/dependency-graph.test.ts +83 -0
  162. package/test/etag.test.ts +193 -0
  163. package/test/fan-transport.test.ts +62 -0
  164. package/test/happydom.ts +24 -0
  165. package/test/inputs-body.test.ts +81 -0
  166. package/test/instance-id.test.ts +104 -0
  167. package/test/live-engine.test.ts +593 -0
  168. package/test/live-post.test.ts +81 -0
  169. package/test/metrics.test.ts +131 -0
  170. package/test/optimistic.test.ts +166 -0
  171. package/test/orm-integration.test.ts +75 -0
  172. package/test/patch-engine.test.ts +121 -0
  173. package/test/pg-bus.test.ts +162 -0
  174. package/test/pg-listen-probe.test.ts +56 -0
  175. package/test/pg-listener.test.ts +134 -0
  176. package/test/pg-notify-emitter.test.ts +113 -0
  177. package/test/pg-notify-integration.test.ts +88 -0
  178. package/test/polling-auth.test.ts +169 -0
  179. package/test/prefetch.test.ts +137 -0
  180. package/test/react-adapter.test.tsx +83 -0
  181. package/test/react-rerender.test.tsx +131 -0
  182. package/test/resource-registry-helper.ts +35 -0
  183. package/test/resource-registry.test.ts +144 -0
  184. package/test/route-pipeline.test.ts +139 -0
  185. package/test/shared.test.ts +53 -0
  186. package/test/sse-routes.test.ts +204 -0
  187. package/test/sse-transport.test.ts +91 -0
  188. package/test/statement-keys.test.ts +126 -0
  189. package/test/subscription-registry.test.ts +61 -0
  190. package/test/transport-ladder.test.ts +293 -0
  191. package/test/transport.test.ts +274 -0
  192. package/test/types/optimistic-types.ts +34 -0
  193. package/test/use-live-action.test.ts +127 -0
  194. package/test/use-live.test.ts +109 -0
  195. package/test/vanilla-adapter.test.ts +200 -0
  196. package/test/vue-adapter.test.ts +122 -0
  197. package/tsconfig.json +15 -0
  198. package/tsconfig.tsbuildinfo +1 -0
  199. package/tsconfig.types.json +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 carno.js
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ import { type LiveAuthorizer } from './auth/authorizer';
2
+ import type { InvalidationBus } from './bus/InvalidationBus';
3
+ import type { LiveConfig } from './config';
4
+ import { DependencyGraph } from './graph/DependencyGraph';
5
+ import { SubscriptionRegistry } from './graph/SubscriptionRegistry';
6
+ import type { ServerMessage } from './shared/protocol';
7
+ import type { ResourceRegistry } from './resource/ResourceRegistry';
8
+ import type { LiveInputs, LiveScope } from './resource/types';
9
+ import { LiveMetrics } from './observability';
10
+ export interface LiveTransport {
11
+ /**
12
+ * Send one message. The return value is the underlying socket's: Bun's
13
+ * `ServerWebSocket.send()` answers -1 under back-pressure and 0 when the
14
+ * message was dropped. Anything <= 0 counts as back-pressure here.
15
+ */
16
+ send(connectionId: string, message: ServerMessage): number;
17
+ }
18
+ export interface LiveStats {
19
+ instances: number;
20
+ recomputes: number;
21
+ /**
22
+ * Recomputes that produced no patch. The most important number in the
23
+ * system: it measures the precision of the invalidation granularity
24
+ * directly. Climbing means the graph is waking instances for nothing.
25
+ */
26
+ recomputesWithoutPatch: number;
27
+ }
28
+ export declare class LiveEngine {
29
+ private readonly resources;
30
+ private readonly graph;
31
+ private readonly subs;
32
+ private readonly bus;
33
+ private readonly transport;
34
+ private readonly config;
35
+ private readonly authorizer;
36
+ private readonly metrics;
37
+ private readonly instances;
38
+ /** One promise per instance under construction; also reserves node capacity. */
39
+ private readonly creating;
40
+ /** connectionId → sid → instanceId. Addressing only; refcount lives in the registry. */
41
+ private readonly bindings;
42
+ private readonly backpressure;
43
+ private readonly pending;
44
+ /** Scope of each connection, as resolved at subscribe time. */
45
+ private readonly scopes;
46
+ /** connectionId → instanceId → decision. Cleared when an `auth:` key fires. */
47
+ private readonly authorized;
48
+ private flushTimer;
49
+ private unsubscribeBus;
50
+ private recomputes;
51
+ private recomputesWithoutPatch;
52
+ constructor(resources: ResourceRegistry, graph: DependencyGraph, subs: SubscriptionRegistry, bus: InvalidationBus, transport: LiveTransport, config: LiveConfig, authorizer?: LiveAuthorizer, metrics?: LiveMetrics);
53
+ start(): void;
54
+ stop(): void;
55
+ subscribe(connectionId: string, sid: string, resourceId: string, inputs: LiveInputs, scope: LiveScope, clientHash?: string): Promise<void>;
56
+ unsubscribe(connectionId: string, sid: string): void;
57
+ resync(connectionId: string, sid: string, clientHash?: string): Promise<void>;
58
+ dropConnection(connectionId: string): void;
59
+ /** Manual invalidation — the third emitter of §4.4. */
60
+ invalidate(key: string): void;
61
+ stats(): LiveStats;
62
+ /**
63
+ * Polling has no server-side subscription to cache an authorization
64
+ * decision against, so it must ask the live authorizer on every request.
65
+ */
66
+ authorizePolling(connectionId: string, resourceId: string, inputs: LiveInputs, scope: LiveScope): Promise<boolean>;
67
+ private bind;
68
+ private release;
69
+ private checkAuthorization;
70
+ /** An `auth:` key fired: drop the cached decisions it covers and re-ask. */
71
+ private reauthorize;
72
+ /** End one connection's hold on one instance, telling it why. */
73
+ private revoke;
74
+ private scheduleDrop;
75
+ private startInstanceCreation;
76
+ private clearInstanceCreation;
77
+ private createInstance;
78
+ private sendState;
79
+ private onInvalidation;
80
+ private flush;
81
+ private recompute;
82
+ private runCompute;
83
+ private broadcast;
84
+ private revokeInstance;
85
+ private sidsFor;
86
+ private isBackedUp;
87
+ private send;
88
+ private fail;
89
+ }
@@ -0,0 +1,520 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LiveEngine = void 0;
4
+ const authorizer_1 = require("./auth/authorizer");
5
+ const dep_key_1 = require("./graph/dep-key");
6
+ const PatchEngine_1 = require("./patch/PatchEngine");
7
+ const canonical_1 = require("./shared/canonical");
8
+ const hash_1 = require("./shared/hash");
9
+ const instance_id_1 = require("./resource/instance-id");
10
+ const route_executor_1 = require("./resource/route-executor");
11
+ const observability_1 = require("./observability");
12
+ class LiveEngine {
13
+ constructor(resources, graph, subs, bus, transport, config, authorizer = new authorizer_1.AllowAllAuthorizer(), metrics = observability_1.LiveMetrics.none()) {
14
+ this.resources = resources;
15
+ this.graph = graph;
16
+ this.subs = subs;
17
+ this.bus = bus;
18
+ this.transport = transport;
19
+ this.config = config;
20
+ this.authorizer = authorizer;
21
+ this.metrics = metrics;
22
+ this.instances = new Map();
23
+ /** One promise per instance under construction; also reserves node capacity. */
24
+ this.creating = new Map();
25
+ /** connectionId → sid → instanceId. Addressing only; refcount lives in the registry. */
26
+ this.bindings = new Map();
27
+ this.backpressure = new Map();
28
+ this.pending = new Set();
29
+ /** Scope of each connection, as resolved at subscribe time. */
30
+ this.scopes = new Map();
31
+ /** connectionId → instanceId → decision. Cleared when an `auth:` key fires. */
32
+ this.authorized = new Map();
33
+ this.flushTimer = null;
34
+ this.unsubscribeBus = null;
35
+ this.recomputes = 0;
36
+ this.recomputesWithoutPatch = 0;
37
+ }
38
+ start() {
39
+ if (this.unsubscribeBus) {
40
+ return;
41
+ }
42
+ this.unsubscribeBus = this.bus.subscribe(events => this.onInvalidation(events));
43
+ }
44
+ stop() {
45
+ this.unsubscribeBus?.();
46
+ this.unsubscribeBus = null;
47
+ if (this.flushTimer) {
48
+ clearTimeout(this.flushTimer);
49
+ this.flushTimer = null;
50
+ }
51
+ for (const instance of this.instances.values()) {
52
+ if (instance.dropTimer) {
53
+ clearTimeout(instance.dropTimer);
54
+ }
55
+ }
56
+ }
57
+ async subscribe(connectionId, sid, resourceId, inputs, scope, clientHash) {
58
+ const resource = this.resources.get(resourceId);
59
+ if (!resource) {
60
+ this.fail(connectionId, sid, 'unknown_resource', `No live resource named "${resourceId}".`);
61
+ return;
62
+ }
63
+ let instanceId;
64
+ try {
65
+ const scopeKey = (0, instance_id_1.scopeKeyOf)(resource.meta.shared, scope);
66
+ instanceId = (0, instance_id_1.instanceIdOf)(resource.id, scopeKey, (0, instance_id_1.canonicalInputs)(inputs, this.config.maxInputBytes));
67
+ }
68
+ catch (error) {
69
+ this.fail(connectionId, sid, 'invalid_subscription', error.message);
70
+ return;
71
+ }
72
+ this.scopes.set(connectionId, scope);
73
+ const allowed = await this.checkAuthorization(connectionId, instanceId, resource, inputs, scope);
74
+ if (!allowed) {
75
+ this.fail(connectionId, sid, 'forbidden', `This connection is not allowed to subscribe to "${resourceId}".`);
76
+ return;
77
+ }
78
+ const known = this.instances.has(instanceId);
79
+ const creating = this.creating.has(instanceId);
80
+ const heldByConnection = this.subs.countForConnection(connectionId);
81
+ if (!this.bindings.get(connectionId)?.has(sid) && heldByConnection >= this.config.maxInstancesPerConnection) {
82
+ this.fail(connectionId, sid, 'too_many_instances', `A connection may hold at most ${this.config.maxInstancesPerConnection} live instances.`);
83
+ return;
84
+ }
85
+ if (!known && !creating && this.instances.size + this.creating.size >= this.config.maxInstancesPerNode) {
86
+ this.fail(connectionId, sid, 'node_at_capacity', 'This node is at its live instance ceiling.');
87
+ return;
88
+ }
89
+ const previous = this.bindings.get(connectionId)?.get(sid);
90
+ if (previous !== instanceId) {
91
+ if (previous !== undefined) {
92
+ this.release(connectionId, sid);
93
+ }
94
+ this.bind(connectionId, sid, instanceId);
95
+ this.subs.subscribe(connectionId, instanceId);
96
+ }
97
+ let instance = this.instances.get(instanceId);
98
+ if (instance?.dropTimer) {
99
+ clearTimeout(instance.dropTimer);
100
+ instance.dropTimer = null;
101
+ }
102
+ if (!instance) {
103
+ const creation = this.creating.get(instanceId)
104
+ ?? this.startInstanceCreation(instanceId, resource, inputs, scope);
105
+ try {
106
+ instance = await creation;
107
+ }
108
+ catch (error) {
109
+ this.release(connectionId, sid);
110
+ const code = error instanceof route_executor_1.LiveRouteExecutionError
111
+ ? error.statusCode === 401 || error.statusCode === 403
112
+ ? 'forbidden'
113
+ : error.statusCode >= 400 && error.statusCode < 500
114
+ ? 'invalid_subscription'
115
+ : 'compute_failed'
116
+ : 'compute_failed';
117
+ this.fail(connectionId, sid, code, error.message);
118
+ return;
119
+ }
120
+ }
121
+ if (this.bindings.get(connectionId)?.get(sid) !== instanceId) {
122
+ this.scheduleDrop(instanceId);
123
+ return;
124
+ }
125
+ this.sendState(connectionId, sid, instance, clientHash);
126
+ }
127
+ unsubscribe(connectionId, sid) {
128
+ this.release(connectionId, sid);
129
+ }
130
+ async resync(connectionId, sid, clientHash) {
131
+ const instanceId = this.bindings.get(connectionId)?.get(sid);
132
+ const instance = instanceId ? this.instances.get(instanceId) : undefined;
133
+ if (!instance) {
134
+ this.fail(connectionId, sid, 'unknown_subscription', 'Resync for a subscription this node does not hold.');
135
+ return;
136
+ }
137
+ this.sendState(connectionId, sid, instance, clientHash);
138
+ }
139
+ dropConnection(connectionId) {
140
+ const owned = this.bindings.get(connectionId);
141
+ if (owned) {
142
+ for (const sid of [...owned.keys()]) {
143
+ this.release(connectionId, sid);
144
+ }
145
+ }
146
+ this.bindings.delete(connectionId);
147
+ this.backpressure.delete(connectionId);
148
+ this.scopes.delete(connectionId);
149
+ this.authorized.delete(connectionId);
150
+ }
151
+ /** Manual invalidation — the third emitter of §4.4. */
152
+ invalidate(key) {
153
+ this.bus.publish([{ key, columns: null }]);
154
+ }
155
+ stats() {
156
+ return {
157
+ instances: this.instances.size,
158
+ recomputes: this.recomputes,
159
+ recomputesWithoutPatch: this.recomputesWithoutPatch
160
+ };
161
+ }
162
+ /**
163
+ * Polling has no server-side subscription to cache an authorization
164
+ * decision against, so it must ask the live authorizer on every request.
165
+ */
166
+ async authorizePolling(connectionId, resourceId, inputs, scope) {
167
+ const resource = this.resources.get(resourceId);
168
+ if (!resource) {
169
+ return false;
170
+ }
171
+ try {
172
+ (0, instance_id_1.scopeKeyOf)(resource.meta.shared, scope);
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ try {
178
+ return await this.authorizer.authorize({
179
+ resourceId: resource.id,
180
+ controllerName: resource.controllerName,
181
+ handlerName: resource.handlerName,
182
+ meta: resource.meta,
183
+ inputs,
184
+ scope,
185
+ connectionId
186
+ });
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ // ------------------------------------------------------------ internals
193
+ bind(connectionId, sid, instanceId) {
194
+ let owned = this.bindings.get(connectionId);
195
+ if (!owned) {
196
+ owned = new Map();
197
+ this.bindings.set(connectionId, owned);
198
+ }
199
+ owned.set(sid, instanceId);
200
+ }
201
+ release(connectionId, sid) {
202
+ const owned = this.bindings.get(connectionId);
203
+ const instanceId = owned?.get(sid);
204
+ if (!owned || !instanceId) {
205
+ return;
206
+ }
207
+ owned.delete(sid);
208
+ this.authorized.get(connectionId)?.delete(instanceId);
209
+ this.subs.unsubscribe(connectionId, instanceId);
210
+ this.scheduleDrop(instanceId);
211
+ }
212
+ async checkAuthorization(connectionId, instanceId, resource, inputs, scope) {
213
+ let perConnection = this.authorized.get(connectionId);
214
+ if (!perConnection) {
215
+ perConnection = new Map();
216
+ this.authorized.set(connectionId, perConnection);
217
+ }
218
+ const cached = perConnection.get(instanceId);
219
+ if (cached !== undefined) {
220
+ return cached;
221
+ }
222
+ let allowed;
223
+ try {
224
+ allowed = await this.authorizer.authorize({
225
+ resourceId: resource.id,
226
+ controllerName: resource.controllerName,
227
+ handlerName: resource.handlerName,
228
+ meta: resource.meta,
229
+ inputs,
230
+ scope,
231
+ connectionId
232
+ });
233
+ }
234
+ catch {
235
+ // An authorizer that throws is a denial. Failing open here would
236
+ // hand out data on a bug in application code.
237
+ allowed = false;
238
+ }
239
+ perConnection.set(instanceId, allowed);
240
+ return allowed;
241
+ }
242
+ /** An `auth:` key fired: drop the cached decisions it covers and re-ask. */
243
+ reauthorize(key) {
244
+ for (const [connectionId, scope] of this.scopes) {
245
+ const affected = (0, authorizer_1.authKeysOf)(scope).some(owned => (0, dep_key_1.ancestorsOf)(owned).includes(key));
246
+ if (!affected) {
247
+ continue;
248
+ }
249
+ this.authorized.delete(connectionId);
250
+ const owned = this.bindings.get(connectionId);
251
+ if (!owned) {
252
+ continue;
253
+ }
254
+ for (const instanceId of new Set(owned.values())) {
255
+ const instance = this.instances.get(instanceId);
256
+ if (!instance) {
257
+ continue;
258
+ }
259
+ void this.checkAuthorization(connectionId, instanceId, instance.resource, instance.inputs, scope)
260
+ .then(allowed => {
261
+ if (!allowed) {
262
+ this.revoke(connectionId, instanceId);
263
+ }
264
+ });
265
+ }
266
+ }
267
+ }
268
+ /** End one connection's hold on one instance, telling it why. */
269
+ revoke(connectionId, instanceId) {
270
+ for (const sid of this.sidsFor(connectionId, instanceId)) {
271
+ this.send(connectionId, {
272
+ t: 'error',
273
+ sid,
274
+ code: 'forbidden',
275
+ message: 'This subscription is no longer authorized for this connection.'
276
+ });
277
+ this.release(connectionId, sid);
278
+ }
279
+ }
280
+ scheduleDrop(instanceId) {
281
+ if (this.subs.hasSubscribers(instanceId)) {
282
+ return;
283
+ }
284
+ const instance = this.instances.get(instanceId);
285
+ if (!instance || instance.dropTimer) {
286
+ return;
287
+ }
288
+ // Grace period so coming back from a navigation does not recompute
289
+ // everything the page had a moment ago.
290
+ instance.dropTimer = setTimeout(() => {
291
+ if (!this.subs.hasSubscribers(instanceId)) {
292
+ this.instances.delete(instanceId);
293
+ this.graph.remove(instanceId);
294
+ }
295
+ }, this.config.unsubGraceMs);
296
+ }
297
+ startInstanceCreation(instanceId, resource, inputs, scope) {
298
+ let resolveCreation;
299
+ let rejectCreation;
300
+ const creation = new Promise((resolve, reject) => {
301
+ resolveCreation = resolve;
302
+ rejectCreation = reject;
303
+ });
304
+ this.creating.set(instanceId, creation);
305
+ void this.createInstance(instanceId, resource, inputs, scope).then(instance => {
306
+ this.clearInstanceCreation(instanceId, creation);
307
+ resolveCreation(instance);
308
+ }, error => {
309
+ this.clearInstanceCreation(instanceId, creation);
310
+ rejectCreation(error);
311
+ });
312
+ return creation;
313
+ }
314
+ clearInstanceCreation(instanceId, creation) {
315
+ if (this.creating.get(instanceId) === creation) {
316
+ this.creating.delete(instanceId);
317
+ }
318
+ }
319
+ async createInstance(instanceId, resource, inputs, scope) {
320
+ const { data, deps } = await this.resources.compute(resource, inputs, { scope });
321
+ this.recomputes++;
322
+ this.graph.setDependencies(instanceId, deps);
323
+ const instance = {
324
+ id: instanceId,
325
+ resource,
326
+ inputs,
327
+ scope,
328
+ patcher: new PatchEngine_1.PatchEngine(resource.meta.key),
329
+ data,
330
+ hash: (0, hash_1.fnv1a64)((0, canonical_1.canonical)(data)),
331
+ revision: 1,
332
+ computing: null,
333
+ dirty: false,
334
+ dropTimer: null
335
+ };
336
+ this.instances.set(instanceId, instance);
337
+ return instance;
338
+ }
339
+ sendState(connectionId, sid, instance, clientHash) {
340
+ if (clientHash && clientHash === instance.hash) {
341
+ // The screen already holds this exact content. Nothing on the wire.
342
+ this.send(connectionId, {
343
+ t: 'current',
344
+ sid,
345
+ rev: instance.revision,
346
+ hash: instance.hash,
347
+ key: instance.resource.meta.key
348
+ });
349
+ return;
350
+ }
351
+ this.send(connectionId, {
352
+ t: 'snapshot',
353
+ sid,
354
+ rev: instance.revision,
355
+ hash: instance.hash,
356
+ data: instance.data,
357
+ key: instance.resource.meta.key
358
+ });
359
+ }
360
+ onInvalidation(events) {
361
+ const before = this.pending.size;
362
+ for (const event of events) {
363
+ if ((0, authorizer_1.isAuthKey)(event.key)) {
364
+ // Not data: nothing to recompute, only permissions to re-check.
365
+ this.reauthorize(event.key);
366
+ continue;
367
+ }
368
+ for (const instanceId of this.graph.resolve(event)) {
369
+ // Grace-held instances have no subscribers but are still cached.
370
+ if (this.instances.has(instanceId)) {
371
+ this.pending.add(instanceId);
372
+ }
373
+ }
374
+ }
375
+ // Newly pending, not total pending: an invalidation that woke nothing
376
+ // because the instances were already queued did not cost a fan-out.
377
+ this.metrics.invalidation(events.length, this.pending.size - before);
378
+ if (this.pending.size === 0 || this.flushTimer) {
379
+ return;
380
+ }
381
+ this.flushTimer = setTimeout(() => {
382
+ this.flushTimer = null;
383
+ void this.flush();
384
+ }, this.config.coalesceMs);
385
+ }
386
+ async flush() {
387
+ const batch = [...this.pending];
388
+ this.pending.clear();
389
+ this.metrics.instances(this.instances.size);
390
+ for (let i = 0; i < batch.length; i += this.config.fanoutQueueThreshold) {
391
+ const slice = batch.slice(i, i + this.config.fanoutQueueThreshold);
392
+ await Promise.all(slice.map(instanceId => this.recompute(instanceId)));
393
+ if (i + this.config.fanoutQueueThreshold < batch.length) {
394
+ // Yield between slices so a large fan-out does not monopolize
395
+ // the loop and stall unrelated requests.
396
+ await new Promise(resolve => setTimeout(resolve, 0));
397
+ }
398
+ }
399
+ }
400
+ recompute(instanceId) {
401
+ const instance = this.instances.get(instanceId);
402
+ if (!instance) {
403
+ return Promise.resolve();
404
+ }
405
+ if (instance.computing) {
406
+ // Single-flight: N invalidations arriving during one recompute cost
407
+ // exactly one more recompute, not N.
408
+ instance.dirty = true;
409
+ return instance.computing;
410
+ }
411
+ instance.computing = this.runCompute(instance).finally(() => {
412
+ instance.computing = null;
413
+ if (instance.dirty) {
414
+ instance.dirty = false;
415
+ void this.recompute(instanceId);
416
+ }
417
+ });
418
+ return instance.computing;
419
+ }
420
+ async runCompute(instance) {
421
+ const startedAt = performance.now();
422
+ let data;
423
+ let deps;
424
+ try {
425
+ ({ data, deps } = await this.resources.compute(instance.resource, instance.inputs, { scope: instance.scope }));
426
+ }
427
+ catch (error) {
428
+ if ((0, route_executor_1.isLiveAuthorizationFailure)(error)) {
429
+ this.revokeInstance(instance.id);
430
+ return;
431
+ }
432
+ await this.broadcast(instance, sid => ({ t: 'stale', sid, reason: error.message }));
433
+ return;
434
+ }
435
+ this.recomputes++;
436
+ this.graph.setDependencies(instance.id, deps);
437
+ const hash = (0, hash_1.fnv1a64)((0, canonical_1.canonical)(data));
438
+ if (hash === instance.hash) {
439
+ // Recompute is not a patch. Coarse invalidation costs CPU, never
440
+ // traffic and never a re-render. This is the number of §10.
441
+ this.recomputesWithoutPatch++;
442
+ this.metrics.recompute(instance.resource.id, false, 0, performance.now() - startedAt);
443
+ return;
444
+ }
445
+ const ops = instance.patcher.diff(instance.data, data);
446
+ const from = instance.revision;
447
+ instance.data = data;
448
+ instance.hash = hash;
449
+ instance.revision += 1;
450
+ this.metrics.recompute(instance.resource.id, true, ops.length, performance.now() - startedAt);
451
+ await this.broadcast(instance, sid => ({
452
+ t: 'patch',
453
+ sid,
454
+ from,
455
+ to: instance.revision,
456
+ hash,
457
+ ops
458
+ }));
459
+ }
460
+ async broadcast(instance, build) {
461
+ for (const connectionId of [...this.subs.connectionsOf(instance.id)]) {
462
+ const scope = this.scopes.get(connectionId);
463
+ const allowed = scope
464
+ ? await this.checkAuthorization(connectionId, instance.id, instance.resource, instance.inputs, scope)
465
+ : false;
466
+ if (!allowed) {
467
+ this.revoke(connectionId, instance.id);
468
+ continue;
469
+ }
470
+ for (const sid of this.sidsFor(connectionId, instance.id)) {
471
+ const message = build(sid);
472
+ if (message.t === 'patch' && this.isBackedUp(connectionId)) {
473
+ // The client is behind. Collapse instead of queueing more.
474
+ this.send(connectionId, {
475
+ t: 'snapshot',
476
+ sid,
477
+ rev: instance.revision,
478
+ hash: instance.hash,
479
+ data: instance.data,
480
+ key: instance.resource.meta.key
481
+ });
482
+ this.backpressure.set(connectionId, 0);
483
+ continue;
484
+ }
485
+ this.send(connectionId, message);
486
+ }
487
+ }
488
+ }
489
+ revokeInstance(instanceId) {
490
+ const connections = new Set(this.subs.connectionsOf(instanceId));
491
+ for (const connectionId of connections) {
492
+ this.revoke(connectionId, instanceId);
493
+ }
494
+ }
495
+ sidsFor(connectionId, instanceId) {
496
+ const owned = this.bindings.get(connectionId);
497
+ if (!owned) {
498
+ return [];
499
+ }
500
+ const sids = [];
501
+ for (const [sid, boundInstance] of owned) {
502
+ if (boundInstance === instanceId) {
503
+ sids.push(sid);
504
+ }
505
+ }
506
+ return sids;
507
+ }
508
+ isBackedUp(connectionId) {
509
+ return (this.backpressure.get(connectionId) ?? 0) >= this.config.maxPendingPatches;
510
+ }
511
+ send(connectionId, message) {
512
+ const result = this.transport.send(connectionId, message);
513
+ const current = this.backpressure.get(connectionId) ?? 0;
514
+ this.backpressure.set(connectionId, result > 0 ? 0 : current + 1);
515
+ }
516
+ fail(connectionId, sid, code, message) {
517
+ this.send(connectionId, { t: 'error', sid, code, message });
518
+ }
519
+ }
520
+ exports.LiveEngine = LiveEngine;
@@ -0,0 +1,55 @@
1
+ import { Carno } from '@carno.js/core';
2
+ import { type WebSocketPluginConfig } from '@carno.js/websocket';
3
+ import { type LiveAuthorizer } from './auth/authorizer';
4
+ import { type PgNotifyTable } from './emitters/pg-notify-emitter';
5
+ import { type LiveConfig } from './config';
6
+ import { type LiveScopeResolver } from './transport/scope-resolver';
7
+ export interface LivePluginOptions {
8
+ /** Controllers holding @Live() handlers. Validated at bootstrap. */
9
+ controllers: (new (...args: any[]) => any)[];
10
+ /**
11
+ * Your own @Gateway classes. They must be listed here rather than passed to
12
+ * a second WebSocketPlugin: Carno.use() keeps only one WebSocket handler
13
+ * builder, so a second plugin silently wins and orphans the first.
14
+ */
15
+ gateways?: (new (...args: any[]) => any)[];
16
+ scopeResolver?: LiveScopeResolver;
17
+ /**
18
+ * Decides whether a connection may hold a subscription, and is re-asked
19
+ * whenever `LiveService.invalidate('auth:principal#<id>')` fires.
20
+ */
21
+ authorizer?: LiveAuthorizer;
22
+ /**
23
+ * Watch these tables with a Postgres trigger, so writes that never went
24
+ * through @carno.js/orm also invalidate. Requires PostgreSQL 11 or newer.
25
+ */
26
+ pgNotify?: {
27
+ tables: PgNotifyTable[];
28
+ /** Defaults to the ORM's own connection string. */
29
+ url?: string;
30
+ channel?: string;
31
+ };
32
+ /** Carry invalidations from this node to the others. */
33
+ distributed?: {
34
+ transport: 'pg-notify';
35
+ url?: string;
36
+ channel?: string;
37
+ nodeId?: string;
38
+ };
39
+ config?: Partial<LiveConfig>;
40
+ websocket?: WebSocketPluginConfig;
41
+ /**
42
+ * Content-hash ETag on live GET routes, so a client with neither
43
+ * WebSocket nor SSE can poll cheaply. On by default: it only ever adds a
44
+ * header, and it only touches routes that are live.
45
+ */
46
+ etag?: boolean;
47
+ /**
48
+ * Serve the protocol over Server-Sent Events as well, for clients whose
49
+ * proxy blocks WebSocket. Off by default: it adds two public routes.
50
+ */
51
+ sse?: boolean;
52
+ }
53
+ export declare class LivePlugin {
54
+ static create(options: LivePluginOptions): Carno;
55
+ }