@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
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { AllowAllAuthorizer, authKeysOf, isAuthKey, type LiveAuthorizer } from './auth/authorizer';
|
|
2
|
+
import type { InvalidationBus } from './bus/InvalidationBus';
|
|
3
|
+
import type { LiveConfig } from './config';
|
|
4
|
+
import { ancestorsOf, type DepKey } from './graph/dep-key';
|
|
5
|
+
import { DependencyGraph } from './graph/DependencyGraph';
|
|
6
|
+
import { SubscriptionRegistry } from './graph/SubscriptionRegistry';
|
|
7
|
+
import type { InvalidationEvent } from './graph/types';
|
|
8
|
+
import { PatchEngine } from './patch/PatchEngine';
|
|
9
|
+
import { canonical } from './shared/canonical';
|
|
10
|
+
import { fnv1a64 } from './shared/hash';
|
|
11
|
+
import type { ServerMessage } from './shared/protocol';
|
|
12
|
+
import {
|
|
13
|
+
canonicalInputs,
|
|
14
|
+
instanceIdOf,
|
|
15
|
+
scopeKeyOf
|
|
16
|
+
} from './resource/instance-id';
|
|
17
|
+
import type { ResourceRegistry } from './resource/ResourceRegistry';
|
|
18
|
+
import type { LiveInputs, LiveResource, LiveScope } from './resource/types';
|
|
19
|
+
import { isLiveAuthorizationFailure, LiveRouteExecutionError } from './resource/route-executor';
|
|
20
|
+
import { LiveMetrics } from './observability';
|
|
21
|
+
|
|
22
|
+
export interface LiveTransport {
|
|
23
|
+
/**
|
|
24
|
+
* Send one message. The return value is the underlying socket's: Bun's
|
|
25
|
+
* `ServerWebSocket.send()` answers -1 under back-pressure and 0 when the
|
|
26
|
+
* message was dropped. Anything <= 0 counts as back-pressure here.
|
|
27
|
+
*/
|
|
28
|
+
send(connectionId: string, message: ServerMessage): number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LiveStats {
|
|
32
|
+
instances: number;
|
|
33
|
+
recomputes: number;
|
|
34
|
+
/**
|
|
35
|
+
* Recomputes that produced no patch. The most important number in the
|
|
36
|
+
* system: it measures the precision of the invalidation granularity
|
|
37
|
+
* directly. Climbing means the graph is waking instances for nothing.
|
|
38
|
+
*/
|
|
39
|
+
recomputesWithoutPatch: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface LiveInstance {
|
|
43
|
+
id: string;
|
|
44
|
+
resource: LiveResource;
|
|
45
|
+
inputs: LiveInputs;
|
|
46
|
+
scope: LiveScope;
|
|
47
|
+
patcher: PatchEngine;
|
|
48
|
+
data: unknown;
|
|
49
|
+
hash: string;
|
|
50
|
+
revision: number;
|
|
51
|
+
computing: Promise<void> | null;
|
|
52
|
+
dirty: boolean;
|
|
53
|
+
dropTimer: ReturnType<typeof setTimeout> | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class LiveEngine {
|
|
57
|
+
private readonly instances = new Map<string, LiveInstance>();
|
|
58
|
+
/** One promise per instance under construction; also reserves node capacity. */
|
|
59
|
+
private readonly creating = new Map<string, Promise<LiveInstance>>();
|
|
60
|
+
/** connectionId → sid → instanceId. Addressing only; refcount lives in the registry. */
|
|
61
|
+
private readonly bindings = new Map<string, Map<string, string>>();
|
|
62
|
+
private readonly backpressure = new Map<string, number>();
|
|
63
|
+
private readonly pending = new Set<string>();
|
|
64
|
+
/** Scope of each connection, as resolved at subscribe time. */
|
|
65
|
+
private readonly scopes = new Map<string, LiveScope>();
|
|
66
|
+
/** connectionId → instanceId → decision. Cleared when an `auth:` key fires. */
|
|
67
|
+
private readonly authorized = new Map<string, Map<string, boolean>>();
|
|
68
|
+
|
|
69
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
private unsubscribeBus: (() => void) | null = null;
|
|
71
|
+
private recomputes = 0;
|
|
72
|
+
private recomputesWithoutPatch = 0;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly resources: ResourceRegistry,
|
|
76
|
+
private readonly graph: DependencyGraph,
|
|
77
|
+
private readonly subs: SubscriptionRegistry,
|
|
78
|
+
private readonly bus: InvalidationBus,
|
|
79
|
+
private readonly transport: LiveTransport,
|
|
80
|
+
private readonly config: LiveConfig,
|
|
81
|
+
private readonly authorizer: LiveAuthorizer = new AllowAllAuthorizer(),
|
|
82
|
+
private readonly metrics: LiveMetrics = LiveMetrics.none()
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
start(): void {
|
|
86
|
+
if (this.unsubscribeBus) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.unsubscribeBus = this.bus.subscribe(events => this.onInvalidation(events));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stop(): void {
|
|
94
|
+
this.unsubscribeBus?.();
|
|
95
|
+
this.unsubscribeBus = null;
|
|
96
|
+
|
|
97
|
+
if (this.flushTimer) {
|
|
98
|
+
clearTimeout(this.flushTimer);
|
|
99
|
+
this.flushTimer = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const instance of this.instances.values()) {
|
|
103
|
+
if (instance.dropTimer) {
|
|
104
|
+
clearTimeout(instance.dropTimer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async subscribe(
|
|
110
|
+
connectionId: string,
|
|
111
|
+
sid: string,
|
|
112
|
+
resourceId: string,
|
|
113
|
+
inputs: LiveInputs,
|
|
114
|
+
scope: LiveScope,
|
|
115
|
+
clientHash?: string
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const resource = this.resources.get(resourceId);
|
|
118
|
+
|
|
119
|
+
if (!resource) {
|
|
120
|
+
this.fail(connectionId, sid, 'unknown_resource', `No live resource named "${resourceId}".`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let instanceId: string;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const scopeKey = scopeKeyOf(resource.meta.shared, scope);
|
|
128
|
+
instanceId = instanceIdOf(resource.id, scopeKey, canonicalInputs(inputs, this.config.maxInputBytes));
|
|
129
|
+
} catch (error) {
|
|
130
|
+
this.fail(connectionId, sid, 'invalid_subscription', (error as Error).message);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.scopes.set(connectionId, scope);
|
|
135
|
+
|
|
136
|
+
const allowed = await this.checkAuthorization(connectionId, instanceId, resource, inputs, scope);
|
|
137
|
+
|
|
138
|
+
if (!allowed) {
|
|
139
|
+
this.fail(
|
|
140
|
+
connectionId,
|
|
141
|
+
sid,
|
|
142
|
+
'forbidden',
|
|
143
|
+
`This connection is not allowed to subscribe to "${resourceId}".`
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const known = this.instances.has(instanceId);
|
|
149
|
+
const creating = this.creating.has(instanceId);
|
|
150
|
+
const heldByConnection = this.subs.countForConnection(connectionId);
|
|
151
|
+
|
|
152
|
+
if (!this.bindings.get(connectionId)?.has(sid) && heldByConnection >= this.config.maxInstancesPerConnection) {
|
|
153
|
+
this.fail(
|
|
154
|
+
connectionId,
|
|
155
|
+
sid,
|
|
156
|
+
'too_many_instances',
|
|
157
|
+
`A connection may hold at most ${this.config.maxInstancesPerConnection} live instances.`
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!known && !creating && this.instances.size + this.creating.size >= this.config.maxInstancesPerNode) {
|
|
163
|
+
this.fail(connectionId, sid, 'node_at_capacity', 'This node is at its live instance ceiling.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const previous = this.bindings.get(connectionId)?.get(sid);
|
|
168
|
+
|
|
169
|
+
if (previous !== instanceId) {
|
|
170
|
+
if (previous !== undefined) {
|
|
171
|
+
this.release(connectionId, sid);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this.bind(connectionId, sid, instanceId);
|
|
175
|
+
this.subs.subscribe(connectionId, instanceId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let instance = this.instances.get(instanceId);
|
|
179
|
+
|
|
180
|
+
if (instance?.dropTimer) {
|
|
181
|
+
clearTimeout(instance.dropTimer);
|
|
182
|
+
instance.dropTimer = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!instance) {
|
|
186
|
+
const creation = this.creating.get(instanceId)
|
|
187
|
+
?? this.startInstanceCreation(instanceId, resource, inputs, scope);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
instance = await creation;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.release(connectionId, sid);
|
|
193
|
+
|
|
194
|
+
const code = error instanceof LiveRouteExecutionError
|
|
195
|
+
? error.statusCode === 401 || error.statusCode === 403
|
|
196
|
+
? 'forbidden'
|
|
197
|
+
: error.statusCode >= 400 && error.statusCode < 500
|
|
198
|
+
? 'invalid_subscription'
|
|
199
|
+
: 'compute_failed'
|
|
200
|
+
: 'compute_failed';
|
|
201
|
+
|
|
202
|
+
this.fail(connectionId, sid, code, (error as Error).message);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.bindings.get(connectionId)?.get(sid) !== instanceId) {
|
|
208
|
+
this.scheduleDrop(instanceId);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.sendState(connectionId, sid, instance, clientHash);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
unsubscribe(connectionId: string, sid: string): void {
|
|
216
|
+
this.release(connectionId, sid);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async resync(connectionId: string, sid: string, clientHash?: string): Promise<void> {
|
|
220
|
+
const instanceId = this.bindings.get(connectionId)?.get(sid);
|
|
221
|
+
const instance = instanceId ? this.instances.get(instanceId) : undefined;
|
|
222
|
+
|
|
223
|
+
if (!instance) {
|
|
224
|
+
this.fail(connectionId, sid, 'unknown_subscription', 'Resync for a subscription this node does not hold.');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.sendState(connectionId, sid, instance, clientHash);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
dropConnection(connectionId: string): void {
|
|
232
|
+
const owned = this.bindings.get(connectionId);
|
|
233
|
+
|
|
234
|
+
if (owned) {
|
|
235
|
+
for (const sid of [...owned.keys()]) {
|
|
236
|
+
this.release(connectionId, sid);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.bindings.delete(connectionId);
|
|
241
|
+
this.backpressure.delete(connectionId);
|
|
242
|
+
this.scopes.delete(connectionId);
|
|
243
|
+
this.authorized.delete(connectionId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Manual invalidation — the third emitter of §4.4. */
|
|
247
|
+
invalidate(key: string): void {
|
|
248
|
+
this.bus.publish([{ key, columns: null }]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
stats(): LiveStats {
|
|
252
|
+
return {
|
|
253
|
+
instances: this.instances.size,
|
|
254
|
+
recomputes: this.recomputes,
|
|
255
|
+
recomputesWithoutPatch: this.recomputesWithoutPatch
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Polling has no server-side subscription to cache an authorization
|
|
261
|
+
* decision against, so it must ask the live authorizer on every request.
|
|
262
|
+
*/
|
|
263
|
+
async authorizePolling(
|
|
264
|
+
connectionId: string,
|
|
265
|
+
resourceId: string,
|
|
266
|
+
inputs: LiveInputs,
|
|
267
|
+
scope: LiveScope
|
|
268
|
+
): Promise<boolean> {
|
|
269
|
+
const resource = this.resources.get(resourceId);
|
|
270
|
+
|
|
271
|
+
if (!resource) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
scopeKeyOf(resource.meta.shared, scope);
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return await this.authorizer.authorize({
|
|
283
|
+
resourceId: resource.id,
|
|
284
|
+
controllerName: resource.controllerName,
|
|
285
|
+
handlerName: resource.handlerName,
|
|
286
|
+
meta: resource.meta,
|
|
287
|
+
inputs,
|
|
288
|
+
scope,
|
|
289
|
+
connectionId
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ------------------------------------------------------------ internals
|
|
297
|
+
|
|
298
|
+
private bind(connectionId: string, sid: string, instanceId: string): void {
|
|
299
|
+
let owned = this.bindings.get(connectionId);
|
|
300
|
+
|
|
301
|
+
if (!owned) {
|
|
302
|
+
owned = new Map<string, string>();
|
|
303
|
+
this.bindings.set(connectionId, owned);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
owned.set(sid, instanceId);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private release(connectionId: string, sid: string): void {
|
|
310
|
+
const owned = this.bindings.get(connectionId);
|
|
311
|
+
const instanceId = owned?.get(sid);
|
|
312
|
+
|
|
313
|
+
if (!owned || !instanceId) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
owned.delete(sid);
|
|
318
|
+
this.authorized.get(connectionId)?.delete(instanceId);
|
|
319
|
+
this.subs.unsubscribe(connectionId, instanceId);
|
|
320
|
+
this.scheduleDrop(instanceId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async checkAuthorization(
|
|
324
|
+
connectionId: string,
|
|
325
|
+
instanceId: string,
|
|
326
|
+
resource: LiveResource,
|
|
327
|
+
inputs: LiveInputs,
|
|
328
|
+
scope: LiveScope
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
let perConnection = this.authorized.get(connectionId);
|
|
331
|
+
|
|
332
|
+
if (!perConnection) {
|
|
333
|
+
perConnection = new Map<string, boolean>();
|
|
334
|
+
this.authorized.set(connectionId, perConnection);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const cached = perConnection.get(instanceId);
|
|
338
|
+
|
|
339
|
+
if (cached !== undefined) {
|
|
340
|
+
return cached;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let allowed: boolean;
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
allowed = await this.authorizer.authorize({
|
|
347
|
+
resourceId: resource.id,
|
|
348
|
+
controllerName: resource.controllerName,
|
|
349
|
+
handlerName: resource.handlerName,
|
|
350
|
+
meta: resource.meta,
|
|
351
|
+
inputs,
|
|
352
|
+
scope,
|
|
353
|
+
connectionId
|
|
354
|
+
});
|
|
355
|
+
} catch {
|
|
356
|
+
// An authorizer that throws is a denial. Failing open here would
|
|
357
|
+
// hand out data on a bug in application code.
|
|
358
|
+
allowed = false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
perConnection.set(instanceId, allowed);
|
|
362
|
+
return allowed;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** An `auth:` key fired: drop the cached decisions it covers and re-ask. */
|
|
366
|
+
private reauthorize(key: DepKey): void {
|
|
367
|
+
for (const [connectionId, scope] of this.scopes) {
|
|
368
|
+
const affected = authKeysOf(scope).some(owned => ancestorsOf(owned).includes(key));
|
|
369
|
+
|
|
370
|
+
if (!affected) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.authorized.delete(connectionId);
|
|
375
|
+
|
|
376
|
+
const owned = this.bindings.get(connectionId);
|
|
377
|
+
|
|
378
|
+
if (!owned) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const instanceId of new Set(owned.values())) {
|
|
383
|
+
const instance = this.instances.get(instanceId);
|
|
384
|
+
|
|
385
|
+
if (!instance) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
void this.checkAuthorization(connectionId, instanceId, instance.resource, instance.inputs, scope)
|
|
390
|
+
.then(allowed => {
|
|
391
|
+
if (!allowed) {
|
|
392
|
+
this.revoke(connectionId, instanceId);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** End one connection's hold on one instance, telling it why. */
|
|
400
|
+
private revoke(connectionId: string, instanceId: string): void {
|
|
401
|
+
for (const sid of this.sidsFor(connectionId, instanceId)) {
|
|
402
|
+
this.send(connectionId, {
|
|
403
|
+
t: 'error',
|
|
404
|
+
sid,
|
|
405
|
+
code: 'forbidden',
|
|
406
|
+
message: 'This subscription is no longer authorized for this connection.'
|
|
407
|
+
});
|
|
408
|
+
this.release(connectionId, sid);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private scheduleDrop(instanceId: string): void {
|
|
413
|
+
if (this.subs.hasSubscribers(instanceId)) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const instance = this.instances.get(instanceId);
|
|
418
|
+
|
|
419
|
+
if (!instance || instance.dropTimer) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Grace period so coming back from a navigation does not recompute
|
|
424
|
+
// everything the page had a moment ago.
|
|
425
|
+
instance.dropTimer = setTimeout(() => {
|
|
426
|
+
if (!this.subs.hasSubscribers(instanceId)) {
|
|
427
|
+
this.instances.delete(instanceId);
|
|
428
|
+
this.graph.remove(instanceId);
|
|
429
|
+
}
|
|
430
|
+
}, this.config.unsubGraceMs);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private startInstanceCreation(
|
|
434
|
+
instanceId: string,
|
|
435
|
+
resource: LiveResource,
|
|
436
|
+
inputs: LiveInputs,
|
|
437
|
+
scope: LiveScope
|
|
438
|
+
): Promise<LiveInstance> {
|
|
439
|
+
let resolveCreation!: (instance: LiveInstance) => void;
|
|
440
|
+
let rejectCreation!: (error: unknown) => void;
|
|
441
|
+
const creation = new Promise<LiveInstance>((resolve, reject) => {
|
|
442
|
+
resolveCreation = resolve;
|
|
443
|
+
rejectCreation = reject;
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
this.creating.set(instanceId, creation);
|
|
447
|
+
|
|
448
|
+
void this.createInstance(instanceId, resource, inputs, scope).then(
|
|
449
|
+
instance => {
|
|
450
|
+
this.clearInstanceCreation(instanceId, creation);
|
|
451
|
+
resolveCreation(instance);
|
|
452
|
+
},
|
|
453
|
+
error => {
|
|
454
|
+
this.clearInstanceCreation(instanceId, creation);
|
|
455
|
+
rejectCreation(error);
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
return creation;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private clearInstanceCreation(instanceId: string, creation: Promise<LiveInstance>): void {
|
|
463
|
+
if (this.creating.get(instanceId) === creation) {
|
|
464
|
+
this.creating.delete(instanceId);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async createInstance(
|
|
469
|
+
instanceId: string,
|
|
470
|
+
resource: LiveResource,
|
|
471
|
+
inputs: LiveInputs,
|
|
472
|
+
scope: LiveScope
|
|
473
|
+
): Promise<LiveInstance> {
|
|
474
|
+
const { data, deps } = await this.resources.compute(resource, inputs, { scope });
|
|
475
|
+
this.recomputes++;
|
|
476
|
+
this.graph.setDependencies(instanceId, deps);
|
|
477
|
+
|
|
478
|
+
const instance: LiveInstance = {
|
|
479
|
+
id: instanceId,
|
|
480
|
+
resource,
|
|
481
|
+
inputs,
|
|
482
|
+
scope,
|
|
483
|
+
patcher: new PatchEngine(resource.meta.key),
|
|
484
|
+
data,
|
|
485
|
+
hash: fnv1a64(canonical(data)),
|
|
486
|
+
revision: 1,
|
|
487
|
+
computing: null,
|
|
488
|
+
dirty: false,
|
|
489
|
+
dropTimer: null
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
this.instances.set(instanceId, instance);
|
|
493
|
+
return instance;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private sendState(
|
|
497
|
+
connectionId: string,
|
|
498
|
+
sid: string,
|
|
499
|
+
instance: LiveInstance,
|
|
500
|
+
clientHash?: string
|
|
501
|
+
): void {
|
|
502
|
+
if (clientHash && clientHash === instance.hash) {
|
|
503
|
+
// The screen already holds this exact content. Nothing on the wire.
|
|
504
|
+
this.send(connectionId, {
|
|
505
|
+
t: 'current',
|
|
506
|
+
sid,
|
|
507
|
+
rev: instance.revision,
|
|
508
|
+
hash: instance.hash,
|
|
509
|
+
key: instance.resource.meta.key
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
this.send(connectionId, {
|
|
515
|
+
t: 'snapshot',
|
|
516
|
+
sid,
|
|
517
|
+
rev: instance.revision,
|
|
518
|
+
hash: instance.hash,
|
|
519
|
+
data: instance.data,
|
|
520
|
+
key: instance.resource.meta.key
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private onInvalidation(events: InvalidationEvent[]): void {
|
|
525
|
+
const before = this.pending.size;
|
|
526
|
+
|
|
527
|
+
for (const event of events) {
|
|
528
|
+
if (isAuthKey(event.key)) {
|
|
529
|
+
// Not data: nothing to recompute, only permissions to re-check.
|
|
530
|
+
this.reauthorize(event.key);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const instanceId of this.graph.resolve(event)) {
|
|
535
|
+
// Grace-held instances have no subscribers but are still cached.
|
|
536
|
+
if (this.instances.has(instanceId)) {
|
|
537
|
+
this.pending.add(instanceId);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Newly pending, not total pending: an invalidation that woke nothing
|
|
543
|
+
// because the instances were already queued did not cost a fan-out.
|
|
544
|
+
this.metrics.invalidation(events.length, this.pending.size - before);
|
|
545
|
+
|
|
546
|
+
if (this.pending.size === 0 || this.flushTimer) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
this.flushTimer = setTimeout(() => {
|
|
551
|
+
this.flushTimer = null;
|
|
552
|
+
void this.flush();
|
|
553
|
+
}, this.config.coalesceMs);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async flush(): Promise<void> {
|
|
557
|
+
const batch = [...this.pending];
|
|
558
|
+
this.pending.clear();
|
|
559
|
+
|
|
560
|
+
this.metrics.instances(this.instances.size);
|
|
561
|
+
|
|
562
|
+
for (let i = 0; i < batch.length; i += this.config.fanoutQueueThreshold) {
|
|
563
|
+
const slice = batch.slice(i, i + this.config.fanoutQueueThreshold);
|
|
564
|
+
await Promise.all(slice.map(instanceId => this.recompute(instanceId)));
|
|
565
|
+
|
|
566
|
+
if (i + this.config.fanoutQueueThreshold < batch.length) {
|
|
567
|
+
// Yield between slices so a large fan-out does not monopolize
|
|
568
|
+
// the loop and stall unrelated requests.
|
|
569
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private recompute(instanceId: string): Promise<void> {
|
|
575
|
+
const instance = this.instances.get(instanceId);
|
|
576
|
+
|
|
577
|
+
if (!instance) {
|
|
578
|
+
return Promise.resolve();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (instance.computing) {
|
|
582
|
+
// Single-flight: N invalidations arriving during one recompute cost
|
|
583
|
+
// exactly one more recompute, not N.
|
|
584
|
+
instance.dirty = true;
|
|
585
|
+
return instance.computing;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
instance.computing = this.runCompute(instance).finally(() => {
|
|
589
|
+
instance.computing = null;
|
|
590
|
+
|
|
591
|
+
if (instance.dirty) {
|
|
592
|
+
instance.dirty = false;
|
|
593
|
+
void this.recompute(instanceId);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
return instance.computing;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async runCompute(instance: LiveInstance): Promise<void> {
|
|
601
|
+
const startedAt = performance.now();
|
|
602
|
+
let data: unknown;
|
|
603
|
+
let deps;
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
({ data, deps } = await this.resources.compute(
|
|
607
|
+
instance.resource,
|
|
608
|
+
instance.inputs,
|
|
609
|
+
{ scope: instance.scope }
|
|
610
|
+
));
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (isLiveAuthorizationFailure(error)) {
|
|
613
|
+
this.revokeInstance(instance.id);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
await this.broadcast(instance, sid => ({ t: 'stale', sid, reason: (error as Error).message }));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
this.recomputes++;
|
|
622
|
+
this.graph.setDependencies(instance.id, deps);
|
|
623
|
+
|
|
624
|
+
const hash = fnv1a64(canonical(data));
|
|
625
|
+
|
|
626
|
+
if (hash === instance.hash) {
|
|
627
|
+
// Recompute is not a patch. Coarse invalidation costs CPU, never
|
|
628
|
+
// traffic and never a re-render. This is the number of §10.
|
|
629
|
+
this.recomputesWithoutPatch++;
|
|
630
|
+
this.metrics.recompute(instance.resource.id, false, 0, performance.now() - startedAt);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const ops = instance.patcher.diff(instance.data, data);
|
|
635
|
+
const from = instance.revision;
|
|
636
|
+
|
|
637
|
+
instance.data = data;
|
|
638
|
+
instance.hash = hash;
|
|
639
|
+
instance.revision += 1;
|
|
640
|
+
|
|
641
|
+
this.metrics.recompute(instance.resource.id, true, ops.length, performance.now() - startedAt);
|
|
642
|
+
|
|
643
|
+
await this.broadcast(instance, sid => ({
|
|
644
|
+
t: 'patch',
|
|
645
|
+
sid,
|
|
646
|
+
from,
|
|
647
|
+
to: instance.revision,
|
|
648
|
+
hash,
|
|
649
|
+
ops
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private async broadcast(
|
|
654
|
+
instance: LiveInstance,
|
|
655
|
+
build: (sid: string) => ServerMessage
|
|
656
|
+
): Promise<void> {
|
|
657
|
+
for (const connectionId of [...this.subs.connectionsOf(instance.id)]) {
|
|
658
|
+
const scope = this.scopes.get(connectionId);
|
|
659
|
+
const allowed = scope
|
|
660
|
+
? await this.checkAuthorization(connectionId, instance.id, instance.resource, instance.inputs, scope)
|
|
661
|
+
: false;
|
|
662
|
+
|
|
663
|
+
if (!allowed) {
|
|
664
|
+
this.revoke(connectionId, instance.id);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
for (const sid of this.sidsFor(connectionId, instance.id)) {
|
|
669
|
+
const message = build(sid);
|
|
670
|
+
|
|
671
|
+
if (message.t === 'patch' && this.isBackedUp(connectionId)) {
|
|
672
|
+
// The client is behind. Collapse instead of queueing more.
|
|
673
|
+
this.send(connectionId, {
|
|
674
|
+
t: 'snapshot',
|
|
675
|
+
sid,
|
|
676
|
+
rev: instance.revision,
|
|
677
|
+
hash: instance.hash,
|
|
678
|
+
data: instance.data,
|
|
679
|
+
key: instance.resource.meta.key
|
|
680
|
+
});
|
|
681
|
+
this.backpressure.set(connectionId, 0);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
this.send(connectionId, message);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private revokeInstance(instanceId: string): void {
|
|
691
|
+
const connections = new Set(this.subs.connectionsOf(instanceId));
|
|
692
|
+
|
|
693
|
+
for (const connectionId of connections) {
|
|
694
|
+
this.revoke(connectionId, instanceId);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private sidsFor(connectionId: string, instanceId: string): string[] {
|
|
699
|
+
const owned = this.bindings.get(connectionId);
|
|
700
|
+
|
|
701
|
+
if (!owned) {
|
|
702
|
+
return [];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const sids: string[] = [];
|
|
706
|
+
|
|
707
|
+
for (const [sid, boundInstance] of owned) {
|
|
708
|
+
if (boundInstance === instanceId) {
|
|
709
|
+
sids.push(sid);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return sids;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private isBackedUp(connectionId: string): boolean {
|
|
717
|
+
return (this.backpressure.get(connectionId) ?? 0) >= this.config.maxPendingPatches;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private send(connectionId: string, message: ServerMessage): void {
|
|
721
|
+
const result = this.transport.send(connectionId, message);
|
|
722
|
+
const current = this.backpressure.get(connectionId) ?? 0;
|
|
723
|
+
|
|
724
|
+
this.backpressure.set(connectionId, result > 0 ? 0 : current + 1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private fail(connectionId: string, sid: string, code: string, message: string): void {
|
|
728
|
+
this.send(connectionId, { t: 'error', sid, code, message });
|
|
729
|
+
}
|
|
730
|
+
}
|