@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,253 @@
|
|
|
1
|
+
import { Carno, ObservabilityService, type Container } from '@carno.js/core';
|
|
2
|
+
import { Orm } from '@carno.js/orm';
|
|
3
|
+
import { WebSocketPlugin, type WebSocketPluginConfig } from '@carno.js/websocket';
|
|
4
|
+
import { AllowAllAuthorizer, type LiveAuthorizer } from './auth/authorizer';
|
|
5
|
+
import { InProcessBus } from './bus/InProcessBus';
|
|
6
|
+
import type { InvalidationBus } from './bus/InvalidationBus';
|
|
7
|
+
import { PgNotifyBus } from './bus/PgNotifyBus';
|
|
8
|
+
import { PgNotifyEmitter, type PgNotifyTable } from './emitters/pg-notify-emitter';
|
|
9
|
+
import type { InvalidationEvent } from './graph/types';
|
|
10
|
+
import { resolveLiveConfig, type LiveConfig } from './config';
|
|
11
|
+
import { AppEmitter } from './emitters/AppEmitter';
|
|
12
|
+
import { DependencyGraph } from './graph/DependencyGraph';
|
|
13
|
+
import { SubscriptionRegistry } from './graph/SubscriptionRegistry';
|
|
14
|
+
import { LiveEngine } from './LiveEngine';
|
|
15
|
+
import { LiveMetrics } from './observability';
|
|
16
|
+
import { LiveService } from './LiveService';
|
|
17
|
+
import { ResourceRegistry } from './resource/ResourceRegistry';
|
|
18
|
+
import { createLiveRouteExecutor } from './resource/route-executor';
|
|
19
|
+
import { setLiveRuntime } from './runtime';
|
|
20
|
+
import { LiveETagMiddleware } from './http/etag';
|
|
21
|
+
import { FanTransport } from './transport/FanTransport';
|
|
22
|
+
import { dropLiveConnection, LiveGateway } from './transport/LiveGateway';
|
|
23
|
+
import { ConnectionScopeResolver, type LiveScopeResolver } from './transport/scope-resolver';
|
|
24
|
+
import { SocketTransport } from './transport/SocketTransport';
|
|
25
|
+
import { SseTransport } from './transport/SseTransport';
|
|
26
|
+
import { createSseRoutes } from './transport/sse-routes';
|
|
27
|
+
import { LIVE_CONNECTION_HEADER, LIVE_TOKEN_HEADER } from './shared/protocol';
|
|
28
|
+
|
|
29
|
+
export interface LivePluginOptions {
|
|
30
|
+
/** Controllers holding @Live() handlers. Validated at bootstrap. */
|
|
31
|
+
controllers: (new (...args: any[]) => any)[];
|
|
32
|
+
/**
|
|
33
|
+
* Your own @Gateway classes. They must be listed here rather than passed to
|
|
34
|
+
* a second WebSocketPlugin: Carno.use() keeps only one WebSocket handler
|
|
35
|
+
* builder, so a second plugin silently wins and orphans the first.
|
|
36
|
+
*/
|
|
37
|
+
gateways?: (new (...args: any[]) => any)[];
|
|
38
|
+
scopeResolver?: LiveScopeResolver;
|
|
39
|
+
/**
|
|
40
|
+
* Decides whether a connection may hold a subscription, and is re-asked
|
|
41
|
+
* whenever `LiveService.invalidate('auth:principal#<id>')` fires.
|
|
42
|
+
*/
|
|
43
|
+
authorizer?: LiveAuthorizer;
|
|
44
|
+
/**
|
|
45
|
+
* Watch these tables with a Postgres trigger, so writes that never went
|
|
46
|
+
* through @carno.js/orm also invalidate. Requires PostgreSQL 11 or newer.
|
|
47
|
+
*/
|
|
48
|
+
pgNotify?: {
|
|
49
|
+
tables: PgNotifyTable[];
|
|
50
|
+
/** Defaults to the ORM's own connection string. */
|
|
51
|
+
url?: string;
|
|
52
|
+
channel?: string;
|
|
53
|
+
};
|
|
54
|
+
/** Carry invalidations from this node to the others. */
|
|
55
|
+
distributed?: {
|
|
56
|
+
transport: 'pg-notify';
|
|
57
|
+
url?: string;
|
|
58
|
+
channel?: string;
|
|
59
|
+
nodeId?: string;
|
|
60
|
+
};
|
|
61
|
+
config?: Partial<LiveConfig>;
|
|
62
|
+
websocket?: WebSocketPluginConfig;
|
|
63
|
+
/**
|
|
64
|
+
* Content-hash ETag on live GET routes, so a client with neither
|
|
65
|
+
* WebSocket nor SSE can poll cheaply. On by default: it only ever adds a
|
|
66
|
+
* header, and it only touches routes that are live.
|
|
67
|
+
*/
|
|
68
|
+
etag?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Serve the protocol over Server-Sent Events as well, for clients whose
|
|
71
|
+
* proxy blocks WebSocket. Off by default: it adds two public routes.
|
|
72
|
+
*/
|
|
73
|
+
sse?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class LivePlugin {
|
|
77
|
+
static create(options: LivePluginOptions): Carno {
|
|
78
|
+
const config = resolveLiveConfig(options.config);
|
|
79
|
+
const resources = new ResourceRegistry();
|
|
80
|
+
const graph = new DependencyGraph();
|
|
81
|
+
const subs = new SubscriptionRegistry();
|
|
82
|
+
const distributedBus = options.distributed
|
|
83
|
+
? new PgNotifyBus({
|
|
84
|
+
url: options.distributed.url ?? '',
|
|
85
|
+
channel: options.distributed.channel,
|
|
86
|
+
nodeId: options.distributed.nodeId
|
|
87
|
+
})
|
|
88
|
+
: null;
|
|
89
|
+
const bus: InvalidationBus = distributedBus ?? new InProcessBus();
|
|
90
|
+
const sockets = new SocketTransport();
|
|
91
|
+
const fan = new FanTransport();
|
|
92
|
+
fan.add(sockets);
|
|
93
|
+
let sink: ObservabilityService | null = null;
|
|
94
|
+
const metrics = new LiveMetrics({
|
|
95
|
+
onMetric: (name, value, tags) => sink?.onMetric(name, value, tags)
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const engine = new LiveEngine(
|
|
99
|
+
resources,
|
|
100
|
+
graph,
|
|
101
|
+
subs,
|
|
102
|
+
bus,
|
|
103
|
+
fan,
|
|
104
|
+
config,
|
|
105
|
+
options.authorizer ?? new AllowAllAuthorizer(),
|
|
106
|
+
metrics
|
|
107
|
+
);
|
|
108
|
+
const emitter = new AppEmitter(bus, config);
|
|
109
|
+
const scopeResolver = options.scopeResolver ?? new ConnectionScopeResolver();
|
|
110
|
+
|
|
111
|
+
const dispose: (() => Promise<void> | void)[] = [() => engine.stop()];
|
|
112
|
+
|
|
113
|
+
setLiveRuntime({
|
|
114
|
+
engine,
|
|
115
|
+
transport: sockets,
|
|
116
|
+
resolver: scopeResolver,
|
|
117
|
+
scopes: new Map(),
|
|
118
|
+
handshakes: new Set(),
|
|
119
|
+
resources,
|
|
120
|
+
dispose
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const plugin = new Carno({ exports: [] });
|
|
124
|
+
plugin.controllers(options.controllers);
|
|
125
|
+
plugin.services([LiveService]);
|
|
126
|
+
|
|
127
|
+
if (options.sse) {
|
|
128
|
+
const sse = new SseTransport({
|
|
129
|
+
heartbeatMs: config.sseHeartbeatMs,
|
|
130
|
+
maxConnections: config.sseMaxConnections,
|
|
131
|
+
onDisconnect: dropLiveConnection
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
fan.add(sse);
|
|
135
|
+
dispose.push(() => sse.stop());
|
|
136
|
+
|
|
137
|
+
const routes = createSseRoutes({
|
|
138
|
+
transport: sse,
|
|
139
|
+
streamPath: config.ssePath,
|
|
140
|
+
controlPath: config.sseControlPath
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
plugin.route('GET', routes.streamPath, routes.stream);
|
|
144
|
+
plugin.route('POST', routes.controlPath, routes.control);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let teachEtag: ((paths: { method: string; path: string; resourceId?: string }[]) => void) | null = null;
|
|
148
|
+
|
|
149
|
+
// Registered now, taught later: `plugin.middlewares()` runs before
|
|
150
|
+
// bootstrap, and the resources are only known inside the builder.
|
|
151
|
+
// The middleware also gates polling, even when ETags are disabled.
|
|
152
|
+
const etag = new LiveETagMiddleware([], { enabled: options.etag !== false });
|
|
153
|
+
etag.setPollingGuard(async ({ resourceId, inputs, request }) => {
|
|
154
|
+
const connectionId = request.headers.get(LIVE_CONNECTION_HEADER);
|
|
155
|
+
|
|
156
|
+
if (!connectionId) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let scope;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
scope = await scopeResolver.resolve({
|
|
164
|
+
connectionId,
|
|
165
|
+
token: request.headers.get(LIVE_TOKEN_HEADER) ?? undefined
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return await engine.authorizePolling(connectionId, resourceId, inputs, scope)
|
|
172
|
+
? scope
|
|
173
|
+
: null;
|
|
174
|
+
});
|
|
175
|
+
plugin.middlewares([etag]);
|
|
176
|
+
teachEtag = paths => etag.setPaths(paths);
|
|
177
|
+
|
|
178
|
+
const websocket = WebSocketPlugin.create(
|
|
179
|
+
[LiveGateway, ...(options.gateways ?? [])],
|
|
180
|
+
options.websocket
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const innerBuilder = websocket._wsHandlerBuilder!;
|
|
184
|
+
const upgradePaths = [...websocket._wsUpgradePaths];
|
|
185
|
+
|
|
186
|
+
plugin.use(websocket);
|
|
187
|
+
|
|
188
|
+
// The builder runs after bootstrap, when the container holds the
|
|
189
|
+
// controller instances and the ORM holds its connection — which is why
|
|
190
|
+
// everything that needs a database URL is started here and not above.
|
|
191
|
+
plugin.wsHandler((container: Container) => {
|
|
192
|
+
// Resolved here and not above: the container does not exist until
|
|
193
|
+
// bootstrap, and an app with no observability plugin never
|
|
194
|
+
// registers one.
|
|
195
|
+
sink = container.has(ObservabilityService) ? container.get(ObservabilityService) : null;
|
|
196
|
+
const routeExecutor = createLiveRouteExecutor(container.get(Carno));
|
|
197
|
+
|
|
198
|
+
for (const ControllerClass of options.controllers) {
|
|
199
|
+
resources.register(ControllerClass, container.get(ControllerClass), routeExecutor);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
teachEtag?.(resources.livePaths());
|
|
203
|
+
|
|
204
|
+
if (options.pgNotify) {
|
|
205
|
+
const driver = Orm.getInstance().driverInstance;
|
|
206
|
+
const deliver = (events: InvalidationEvent[]): void => {
|
|
207
|
+
// A trigger already notified every node. Publishing it on
|
|
208
|
+
// the bus would send it around a second time.
|
|
209
|
+
if (distributedBus) {
|
|
210
|
+
distributedBus.publishLocal(events);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
bus.publish(events);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const pgEmitter = new PgNotifyEmitter(deliver, {
|
|
218
|
+
tables: options.pgNotify.tables,
|
|
219
|
+
url: options.pgNotify.url ?? driver.connectionString,
|
|
220
|
+
channel: options.pgNotify.channel,
|
|
221
|
+
execute: sql => driver.executeSql(sql)
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Two emitters on one table would wake the same instance twice.
|
|
225
|
+
emitter.setCoveredTables(pgEmitter.coveredTables());
|
|
226
|
+
dispose.push(() => pgEmitter.detach());
|
|
227
|
+
|
|
228
|
+
void pgEmitter.attach().catch(error => {
|
|
229
|
+
console.error('[carno:live] the Postgres emitter failed to attach', error);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (distributedBus) {
|
|
234
|
+
if (!options.distributed?.url) {
|
|
235
|
+
distributedBus.setUrl(Orm.getInstance().driverInstance.connectionString);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
void distributedBus.start().catch(error => {
|
|
239
|
+
console.error('[carno:live] the distributed bus failed to start', error);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
dispose.push(() => distributedBus.stop());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
emitter.attach();
|
|
246
|
+
engine.start();
|
|
247
|
+
|
|
248
|
+
return innerBuilder(container);
|
|
249
|
+
}, upgradePaths);
|
|
250
|
+
|
|
251
|
+
return plugin;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Service } from '@carno.js/core';
|
|
2
|
+
import { getLiveRuntime } from './runtime';
|
|
3
|
+
import { prefetchLive, type LivePayload } from './resource/prefetch';
|
|
4
|
+
import { resourceIdOf, type LiveDescriptor } from './shared/descriptor';
|
|
5
|
+
import type { LiveExecutionContext, LiveInputs } from './resource/types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manual invalidation — the third emitter of §4.4, for data the ORM cannot
|
|
9
|
+
* see: a rebuilt report, a webhook, an external cache.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* @Service()
|
|
14
|
+
* export class ReportJob {
|
|
15
|
+
* constructor(private readonly live: LiveService) {}
|
|
16
|
+
*
|
|
17
|
+
* @Cron('0 * * * *')
|
|
18
|
+
* async run() {
|
|
19
|
+
* await this.rebuild();
|
|
20
|
+
* this.live.invalidate('app:report:current');
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
@Service()
|
|
26
|
+
export class LiveService {
|
|
27
|
+
invalidate(key: string): void {
|
|
28
|
+
getLiveRuntime().engine.invalidate(key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute a live resource for a server-rendered first paint.
|
|
33
|
+
*
|
|
34
|
+
* Hand the payload to the template; the client starts full and its
|
|
35
|
+
* subscription carries only the hash, so the first screen costs one
|
|
36
|
+
* request instead of two and the data is never sent twice.
|
|
37
|
+
*/
|
|
38
|
+
prefetch(
|
|
39
|
+
resource: string | LiveDescriptor<any>,
|
|
40
|
+
inputs: Partial<LiveInputs> = {},
|
|
41
|
+
context: LiveExecutionContext = {}
|
|
42
|
+
): Promise<LivePayload> {
|
|
43
|
+
const resourceId = typeof resource === 'string' ? resource : resourceIdOf(resource);
|
|
44
|
+
|
|
45
|
+
return prefetchLive(getLiveRuntime().resources, resourceId, inputs, context);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { LiveMeta } from '../metadata';
|
|
2
|
+
import type { LiveInputs, LiveScope } from '../shared/inputs';
|
|
3
|
+
|
|
4
|
+
export interface LiveAuthorizationRequest {
|
|
5
|
+
resourceId: string;
|
|
6
|
+
controllerName: string;
|
|
7
|
+
handlerName: string;
|
|
8
|
+
meta: LiveMeta;
|
|
9
|
+
inputs: LiveInputs;
|
|
10
|
+
scope: LiveScope;
|
|
11
|
+
connectionId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Decides whether one connection may hold one subscription.
|
|
16
|
+
*
|
|
17
|
+
* Called when the subscription is created and again whenever the connection's
|
|
18
|
+
* `auth:` key is invalidated. Returning false ends that connection's
|
|
19
|
+
* subscription; it never affects the other subscribers of a shared instance.
|
|
20
|
+
*/
|
|
21
|
+
export interface LiveAuthorizer {
|
|
22
|
+
authorize(request: LiveAuthorizationRequest): boolean | Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Default. The scope already isolates instances, so nothing extra is refused. */
|
|
26
|
+
export class AllowAllAuthorizer implements LiveAuthorizer {
|
|
27
|
+
authorize(): boolean {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const AUTH_PREFIX = 'auth:';
|
|
33
|
+
|
|
34
|
+
export function isAuthKey(key: string): boolean {
|
|
35
|
+
return key.startsWith(AUTH_PREFIX);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The authorization keys a connection with this scope answers to.
|
|
40
|
+
*
|
|
41
|
+
* Same two-level shape as the ORM keys: `auth:principal#42` is contained by
|
|
42
|
+
* `auth:principal`, so invalidating the parent re-checks everyone.
|
|
43
|
+
*/
|
|
44
|
+
export function authKeysOf(scope: LiveScope): string[] {
|
|
45
|
+
const keys: string[] = [];
|
|
46
|
+
|
|
47
|
+
if (scope.principal !== undefined && scope.principal !== null && scope.principal !== '') {
|
|
48
|
+
keys.push(`${AUTH_PREFIX}principal#${scope.principal}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (scope.tenant !== undefined && scope.tenant !== null && scope.tenant !== '') {
|
|
52
|
+
keys.push(`${AUTH_PREFIX}tenant#${scope.tenant}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return keys;
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InvalidationBus, InvalidationHandler } from './InvalidationBus';
|
|
2
|
+
import type { InvalidationEvent } from '../graph/types';
|
|
3
|
+
|
|
4
|
+
/** Single-process bus. Correct for one node; phase 2 adds the distributed ones. */
|
|
5
|
+
export class InProcessBus implements InvalidationBus {
|
|
6
|
+
private readonly handlers = new Set<InvalidationHandler>();
|
|
7
|
+
|
|
8
|
+
publish(events: InvalidationEvent[]): void {
|
|
9
|
+
if (events.length === 0) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
for (const handler of this.handlers) {
|
|
14
|
+
try {
|
|
15
|
+
handler(events);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
// One broken subscriber must not swallow invalidations for the
|
|
18
|
+
// others: a dropped invalidation is a screen frozen on stale data.
|
|
19
|
+
console.error('[carno:live] invalidation handler failed', error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
subscribe(handler: InvalidationHandler): () => void {
|
|
25
|
+
this.handlers.add(handler);
|
|
26
|
+
return () => this.handlers.delete(handler);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InvalidationEvent } from '../graph/types';
|
|
2
|
+
|
|
3
|
+
export type InvalidationHandler = (events: InvalidationEvent[]) => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Carries invalidations from an emitter to the nodes holding subscriptions.
|
|
7
|
+
*
|
|
8
|
+
* The graph does not care where an invalidation came from, which is why the
|
|
9
|
+
* ORM emitter, the Postgres emitter and manual invalidation are three
|
|
10
|
+
* implementations feeding one interface. Phase 1 ships the in-process one;
|
|
11
|
+
* Redis and pg_notify buses arrive in phase 2 without touching the graph.
|
|
12
|
+
*/
|
|
13
|
+
export interface InvalidationBus {
|
|
14
|
+
publish(events: InvalidationEvent[]): void;
|
|
15
|
+
/** Returns an unsubscribe function. */
|
|
16
|
+
subscribe(handler: InvalidationHandler): () => void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { tableKey, tableOfKey } from '../graph/dep-key';
|
|
2
|
+
import type { InvalidationEvent } from '../graph/types';
|
|
3
|
+
import { PgListener } from '../emitters/pg-listener';
|
|
4
|
+
import type { InvalidationBus, InvalidationHandler } from './InvalidationBus';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PG_BUS_CHANNEL = 'carno_live_bus';
|
|
7
|
+
export const DEFAULT_PG_BUS_MAX_PAYLOAD_BYTES = 7000;
|
|
8
|
+
|
|
9
|
+
export interface PgNotifyBusOptions {
|
|
10
|
+
/** May be empty at construction; `setUrl` fills it before `start()`. */
|
|
11
|
+
url: string;
|
|
12
|
+
channel?: string;
|
|
13
|
+
/** Identifies this process so its own echo can be dropped. */
|
|
14
|
+
nodeId?: string;
|
|
15
|
+
maxPayloadBytes?: number;
|
|
16
|
+
heartbeatMs?: number;
|
|
17
|
+
retryMs?: number;
|
|
18
|
+
/** Injected in tests. */
|
|
19
|
+
listener?: PgListener;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface WireFrame {
|
|
23
|
+
n: string;
|
|
24
|
+
e: InvalidationEvent[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Split events into `pg_notify` frames under the payload ceiling.
|
|
29
|
+
*
|
|
30
|
+
* A single event that does not fit on its own degrades to its table key: a
|
|
31
|
+
* coarse invalidation costs CPU, a dropped one costs a screen frozen on stale
|
|
32
|
+
* data.
|
|
33
|
+
*/
|
|
34
|
+
export function chunkEvents(
|
|
35
|
+
events: InvalidationEvent[],
|
|
36
|
+
nodeId: string,
|
|
37
|
+
maxPayloadBytes: number
|
|
38
|
+
): string[] {
|
|
39
|
+
const encode = (items: InvalidationEvent[]): string => JSON.stringify({ n: nodeId, e: items });
|
|
40
|
+
const frames: string[] = [];
|
|
41
|
+
let batch: InvalidationEvent[] = [];
|
|
42
|
+
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
let item = event;
|
|
45
|
+
|
|
46
|
+
if (Buffer.byteLength(encode([item]), 'utf8') > maxPayloadBytes) {
|
|
47
|
+
const table = tableOfKey(item.key);
|
|
48
|
+
item = { key: table ? tableKey(table) : item.key, columns: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (batch.length > 0 && Buffer.byteLength(encode([...batch, item]), 'utf8') > maxPayloadBytes) {
|
|
52
|
+
frames.push(encode(batch));
|
|
53
|
+
batch = [item];
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
batch.push(item);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (batch.length > 0) {
|
|
61
|
+
frames.push(encode(batch));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return frames;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Invalidation bus across nodes, over `LISTEN/NOTIFY`.
|
|
69
|
+
*
|
|
70
|
+
* Two entry points, on purpose. `publish` is for a source local to this node —
|
|
71
|
+
* the ORM emitter, `LiveService.invalidate()` — and has to reach the others.
|
|
72
|
+
* `publishLocal` is for a source that already reached every node, which is what
|
|
73
|
+
* a Postgres trigger is; re-publishing that would multiply one write by the
|
|
74
|
+
* size of the cluster.
|
|
75
|
+
*/
|
|
76
|
+
export class PgNotifyBus implements InvalidationBus {
|
|
77
|
+
readonly nodeId: string;
|
|
78
|
+
|
|
79
|
+
private readonly handlers = new Set<InvalidationHandler>();
|
|
80
|
+
private readonly channel: string;
|
|
81
|
+
private readonly maxPayloadBytes: number;
|
|
82
|
+
private listener: PgListener | null;
|
|
83
|
+
private url: string;
|
|
84
|
+
|
|
85
|
+
constructor(private readonly options: PgNotifyBusOptions) {
|
|
86
|
+
this.nodeId = options.nodeId ?? crypto.randomUUID();
|
|
87
|
+
this.channel = options.channel ?? DEFAULT_PG_BUS_CHANNEL;
|
|
88
|
+
this.maxPayloadBytes = options.maxPayloadBytes ?? DEFAULT_PG_BUS_MAX_PAYLOAD_BYTES;
|
|
89
|
+
this.listener = options.listener ?? null;
|
|
90
|
+
this.url = options.url;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Called by the plugin once the ORM knows where the database is. */
|
|
94
|
+
setUrl(url: string): void {
|
|
95
|
+
this.url = url;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async start(): Promise<void> {
|
|
99
|
+
if (!this.listener) {
|
|
100
|
+
this.listener = new PgListener({
|
|
101
|
+
url: this.url,
|
|
102
|
+
heartbeatMs: this.options.heartbeatMs,
|
|
103
|
+
retryMs: this.options.retryMs
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await this.listener.listen(this.channel, raw => this.onFrame(raw));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async stop(): Promise<void> {
|
|
111
|
+
await this.listener?.close();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
publish(events: InvalidationEvent[]): void {
|
|
115
|
+
if (events.length === 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.deliver(events);
|
|
120
|
+
void this.broadcast(events);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Deliver here only: the source already reached every node. */
|
|
124
|
+
publishLocal(events: InvalidationEvent[]): void {
|
|
125
|
+
if (events.length === 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.deliver(events);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
subscribe(handler: InvalidationHandler): () => void {
|
|
133
|
+
this.handlers.add(handler);
|
|
134
|
+
return () => this.handlers.delete(handler);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------ internals
|
|
138
|
+
|
|
139
|
+
private async broadcast(events: InvalidationEvent[]): Promise<void> {
|
|
140
|
+
const listener = this.listener;
|
|
141
|
+
|
|
142
|
+
if (!listener) {
|
|
143
|
+
console.error('[carno:live] the distributed bus is not started; invalidation stayed local');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const frame of chunkEvents(events, this.nodeId, this.maxPayloadBytes)) {
|
|
148
|
+
try {
|
|
149
|
+
await listener.notify(this.channel, frame);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// The other nodes will miss this one. Loud, because the symptom
|
|
152
|
+
// over there is a screen that simply stops updating.
|
|
153
|
+
console.error('[carno:live] failed to broadcast an invalidation', error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private onFrame(raw: string): void {
|
|
159
|
+
let frame: WireFrame;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
frame = JSON.parse(raw) as WireFrame;
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!frame || typeof frame.n !== 'string' || !Array.isArray(frame.e)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (frame.n === this.nodeId) {
|
|
172
|
+
// Our own echo. These were delivered locally before being sent.
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.deliver(frame.e);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private deliver(events: InvalidationEvent[]): void {
|
|
180
|
+
for (const handler of this.handlers) {
|
|
181
|
+
try {
|
|
182
|
+
handler(events);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error('[carno:live] invalidation handler failed', error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|