@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.
- package/LICENSE +21 -0
- package/dist/LiveEngine.d.ts +89 -0
- package/dist/LiveEngine.js +520 -0
- package/dist/LivePlugin.d.ts +55 -0
- package/dist/LivePlugin.js +165 -0
- package/dist/LiveService.d.ts +32 -0
- package/dist/LiveService.js +51 -0
- package/dist/auth/authorizer.d.ts +33 -0
- package/dist/auth/authorizer.js +32 -0
- package/dist/bus/InProcessBus.d.ts +8 -0
- package/dist/bus/InProcessBus.js +29 -0
- package/dist/bus/InvalidationBus.d.ts +15 -0
- package/dist/bus/InvalidationBus.js +2 -0
- package/dist/bus/PgNotifyBus.d.ts +55 -0
- package/dist/bus/PgNotifyBus.js +138 -0
- package/dist/client/angular.d.ts +19 -0
- package/dist/client/angular.js +71 -0
- package/dist/client/core.d.ts +111 -0
- package/dist/client/core.js +337 -0
- package/dist/client/hydrate.d.ts +19 -0
- package/dist/client/hydrate.js +65 -0
- package/dist/client/optimistic.d.ts +17 -0
- package/dist/client/optimistic.js +2 -0
- package/dist/client/react.d.ts +22 -0
- package/dist/client/react.js +69 -0
- package/dist/client/transport.d.ts +133 -0
- package/dist/client/transport.js +431 -0
- package/dist/client/vanilla.d.ts +54 -0
- package/dist/client/vanilla.js +111 -0
- package/dist/client/vue.d.ts +12 -0
- package/dist/client/vue.js +35 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +21 -0
- package/dist/decorators/Live.d.ts +4 -0
- package/dist/decorators/Live.js +17 -0
- package/dist/emitters/AppEmitter.d.ts +20 -0
- package/dist/emitters/AppEmitter.js +59 -0
- package/dist/emitters/pg-listener.d.ts +50 -0
- package/dist/emitters/pg-listener.js +120 -0
- package/dist/emitters/pg-notify-emitter.d.ts +59 -0
- package/dist/emitters/pg-notify-emitter.js +105 -0
- package/dist/emitters/pg-trigger-sql.d.ts +27 -0
- package/dist/emitters/pg-trigger-sql.js +107 -0
- package/dist/emitters/statement-keys.d.ts +16 -0
- package/dist/emitters/statement-keys.js +182 -0
- package/dist/graph/DependencyGraph.d.ts +27 -0
- package/dist/graph/DependencyGraph.js +118 -0
- package/dist/graph/SubscriptionRegistry.d.ts +22 -0
- package/dist/graph/SubscriptionRegistry.js +92 -0
- package/dist/graph/dep-key.d.ts +14 -0
- package/dist/graph/dep-key.js +37 -0
- package/dist/graph/types.d.ts +13 -0
- package/dist/graph/types.js +2 -0
- package/dist/http/etag.d.ts +41 -0
- package/dist/http/etag.js +109 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +111 -0
- package/dist/metadata.d.ts +16 -0
- package/dist/metadata.js +4 -0
- package/dist/observability.d.ts +25 -0
- package/dist/observability.js +44 -0
- package/dist/patch/PatchEngine.d.ts +20 -0
- package/dist/patch/PatchEngine.js +180 -0
- package/dist/patch/types.d.ts +33 -0
- package/dist/patch/types.js +2 -0
- package/dist/resource/ResourceRegistry.d.ts +32 -0
- package/dist/resource/ResourceRegistry.js +126 -0
- package/dist/resource/dependency-context.d.ts +25 -0
- package/dist/resource/dependency-context.js +45 -0
- package/dist/resource/instance-id.d.ts +17 -0
- package/dist/resource/instance-id.js +61 -0
- package/dist/resource/prefetch.d.ts +20 -0
- package/dist/resource/prefetch.js +31 -0
- package/dist/resource/route-executor.d.ts +13 -0
- package/dist/resource/route-executor.js +86 -0
- package/dist/resource/types.d.ts +26 -0
- package/dist/resource/types.js +2 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +47 -0
- package/dist/shared/canonical.d.ts +14 -0
- package/dist/shared/canonical.js +60 -0
- package/dist/shared/descriptor.d.ts +44 -0
- package/dist/shared/descriptor.js +19 -0
- package/dist/shared/hash.d.ts +12 -0
- package/dist/shared/hash.js +29 -0
- package/dist/shared/inputs.d.ts +19 -0
- package/dist/shared/inputs.js +2 -0
- package/dist/shared/protocol.d.ts +83 -0
- package/dist/shared/protocol.js +9 -0
- package/dist/transport/FanTransport.d.ts +18 -0
- package/dist/transport/FanTransport.js +29 -0
- package/dist/transport/LiveGateway.d.ts +16 -0
- package/dist/transport/LiveGateway.js +126 -0
- package/dist/transport/SocketTransport.d.ts +18 -0
- package/dist/transport/SocketTransport.js +40 -0
- package/dist/transport/SseTransport.d.ts +30 -0
- package/dist/transport/SseTransport.js +92 -0
- package/dist/transport/scope-resolver.d.ts +20 -0
- package/dist/transport/scope-resolver.js +17 -0
- package/dist/transport/sse-routes.d.ts +24 -0
- package/dist/transport/sse-routes.js +78 -0
- package/package.json +92 -0
- package/src/LiveEngine.ts +730 -0
- package/src/LivePlugin.ts +253 -0
- package/src/LiveService.ts +47 -0
- package/src/auth/authorizer.ts +56 -0
- package/src/bus/InProcessBus.ts +28 -0
- package/src/bus/InvalidationBus.ts +17 -0
- package/src/bus/PgNotifyBus.ts +188 -0
- package/src/client/angular.ts +115 -0
- package/src/client/core.ts +492 -0
- package/src/client/hydrate.ts +80 -0
- package/src/client/optimistic.ts +19 -0
- package/src/client/react.ts +113 -0
- package/src/client/transport.ts +568 -0
- package/src/client/vanilla.ts +165 -0
- package/src/client/vue.ts +67 -0
- package/src/config.ts +49 -0
- package/src/decorators/Live.ts +16 -0
- package/src/emitters/AppEmitter.ts +69 -0
- package/src/emitters/pg-listener.ts +171 -0
- package/src/emitters/pg-notify-emitter.ts +143 -0
- package/src/emitters/pg-trigger-sql.ts +111 -0
- package/src/emitters/statement-keys.ts +229 -0
- package/src/graph/DependencyGraph.ts +147 -0
- package/src/graph/SubscriptionRegistry.ts +109 -0
- package/src/graph/dep-key.ts +41 -0
- package/src/graph/types.ts +15 -0
- package/src/http/etag.ts +144 -0
- package/src/index.ts +81 -0
- package/src/metadata.ts +21 -0
- package/src/observability.ts +54 -0
- package/src/patch/PatchEngine.ts +235 -0
- package/src/patch/types.ts +39 -0
- package/src/resource/ResourceRegistry.ts +178 -0
- package/src/resource/dependency-context.ts +48 -0
- package/src/resource/instance-id.ts +65 -0
- package/src/resource/prefetch.ts +49 -0
- package/src/resource/route-executor.ts +134 -0
- package/src/resource/types.ts +35 -0
- package/src/runtime.ts +69 -0
- package/src/shared/canonical.ts +63 -0
- package/src/shared/descriptor.ts +44 -0
- package/src/shared/hash.ts +31 -0
- package/src/shared/inputs.ts +20 -0
- package/src/shared/protocol.ts +96 -0
- package/src/transport/FanTransport.ts +34 -0
- package/src/transport/LiveGateway.ts +124 -0
- package/src/transport/SocketTransport.ts +43 -0
- package/src/transport/SseTransport.ts +112 -0
- package/src/transport/scope-resolver.ts +25 -0
- package/src/transport/sse-routes.ts +94 -0
- package/test/acceptance-fase-2.test.ts +248 -0
- package/test/acceptance-fase-3.test.ts +216 -0
- package/test/acceptance.test.ts +306 -0
- package/test/angular-adapter.test.ts +151 -0
- package/test/app-emitter.test.ts +112 -0
- package/test/authorization.test.ts +175 -0
- package/test/client-core.test.ts +287 -0
- package/test/client-transport.test.ts +154 -0
- package/test/dependency-graph.test.ts +83 -0
- package/test/etag.test.ts +193 -0
- package/test/fan-transport.test.ts +62 -0
- package/test/happydom.ts +24 -0
- package/test/inputs-body.test.ts +81 -0
- package/test/instance-id.test.ts +104 -0
- package/test/live-engine.test.ts +593 -0
- package/test/live-post.test.ts +81 -0
- package/test/metrics.test.ts +131 -0
- package/test/optimistic.test.ts +166 -0
- package/test/orm-integration.test.ts +75 -0
- package/test/patch-engine.test.ts +121 -0
- package/test/pg-bus.test.ts +162 -0
- package/test/pg-listen-probe.test.ts +56 -0
- package/test/pg-listener.test.ts +134 -0
- package/test/pg-notify-emitter.test.ts +113 -0
- package/test/pg-notify-integration.test.ts +88 -0
- package/test/polling-auth.test.ts +169 -0
- package/test/prefetch.test.ts +137 -0
- package/test/react-adapter.test.tsx +83 -0
- package/test/react-rerender.test.tsx +131 -0
- package/test/resource-registry-helper.ts +35 -0
- package/test/resource-registry.test.ts +144 -0
- package/test/route-pipeline.test.ts +139 -0
- package/test/shared.test.ts +53 -0
- package/test/sse-routes.test.ts +204 -0
- package/test/sse-transport.test.ts +91 -0
- package/test/statement-keys.test.ts +126 -0
- package/test/subscription-registry.test.ts +61 -0
- package/test/transport-ladder.test.ts +293 -0
- package/test/transport.test.ts +274 -0
- package/test/types/optimistic-types.ts +34 -0
- package/test/use-live-action.test.ts +127 -0
- package/test/use-live.test.ts +109 -0
- package/test/vanilla-adapter.test.ts +200 -0
- package/test/vue-adapter.test.ts +122 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|