@carno.js/live 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/LivePlugin.ts CHANGED
@@ -1,253 +1,276 @@
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
- }
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
+ import { defaultScopeWarning } from './scope-warning';
29
+
30
+ export interface LivePluginOptions {
31
+ /** Controllers holding @Live() handlers. Validated at bootstrap. */
32
+ controllers: (new (...args: any[]) => any)[];
33
+ /**
34
+ * Your own @Gateway classes. They must be listed here rather than passed to
35
+ * a second WebSocketPlugin: Carno.use() keeps only one WebSocket handler
36
+ * builder, so a second plugin silently wins and orphans the first.
37
+ */
38
+ gateways?: (new (...args: any[]) => any)[];
39
+ /**
40
+ * Turns a handshake into the principal and tenant that key an instance.
41
+ *
42
+ * Left out, the default `ConnectionScopeResolver` makes the connection id
43
+ * the principal, so every `private` resource gets one instance per
44
+ * connection and the boot logs say so. Passing
45
+ * `new ConnectionScopeResolver()` explicitly is how an application states
46
+ * that per-connection instances are what it wants, and silences that.
47
+ */
48
+ scopeResolver?: LiveScopeResolver;
49
+ /**
50
+ * Decides whether a connection may hold a subscription, and is re-asked
51
+ * whenever `LiveService.invalidate('auth:principal#<id>')` fires.
52
+ */
53
+ authorizer?: LiveAuthorizer;
54
+ /**
55
+ * Watch these tables with a Postgres trigger, so writes that never went
56
+ * through @carno.js/orm also invalidate. Requires PostgreSQL 11 or newer.
57
+ */
58
+ pgNotify?: {
59
+ tables: PgNotifyTable[];
60
+ /** Defaults to the ORM's own connection string. */
61
+ url?: string;
62
+ channel?: string;
63
+ };
64
+ /** Carry invalidations from this node to the others. */
65
+ distributed?: {
66
+ transport: 'pg-notify';
67
+ url?: string;
68
+ channel?: string;
69
+ nodeId?: string;
70
+ };
71
+ config?: Partial<LiveConfig>;
72
+ websocket?: WebSocketPluginConfig;
73
+ /**
74
+ * Content-hash ETag on live GET routes, so a client with neither
75
+ * WebSocket nor SSE can poll cheaply. On by default: it only ever adds a
76
+ * header, and it only touches routes that are live.
77
+ */
78
+ etag?: boolean;
79
+ /**
80
+ * Serve the protocol over Server-Sent Events as well, for clients whose
81
+ * proxy blocks WebSocket. Off by default: it adds two public routes.
82
+ */
83
+ sse?: boolean;
84
+ }
85
+
86
+ export class LivePlugin {
87
+ static create(options: LivePluginOptions): Carno {
88
+ const config = resolveLiveConfig(options.config);
89
+ const resources = new ResourceRegistry();
90
+ const graph = new DependencyGraph();
91
+ const subs = new SubscriptionRegistry();
92
+ const distributedBus = options.distributed
93
+ ? new PgNotifyBus({
94
+ url: options.distributed.url ?? '',
95
+ channel: options.distributed.channel,
96
+ nodeId: options.distributed.nodeId
97
+ })
98
+ : null;
99
+ const bus: InvalidationBus = distributedBus ?? new InProcessBus();
100
+ const sockets = new SocketTransport();
101
+ const fan = new FanTransport();
102
+ fan.add(sockets);
103
+ let sink: ObservabilityService | null = null;
104
+ const metrics = new LiveMetrics({
105
+ onMetric: (name, value, tags) => sink?.onMetric(name, value, tags)
106
+ });
107
+
108
+ const engine = new LiveEngine(
109
+ resources,
110
+ graph,
111
+ subs,
112
+ bus,
113
+ fan,
114
+ config,
115
+ options.authorizer ?? new AllowAllAuthorizer(),
116
+ metrics
117
+ );
118
+ const emitter = new AppEmitter(bus, config);
119
+ const scopeResolver = options.scopeResolver ?? new ConnectionScopeResolver();
120
+
121
+ const dispose: (() => Promise<void> | void)[] = [() => engine.stop()];
122
+
123
+ setLiveRuntime({
124
+ engine,
125
+ transport: sockets,
126
+ resolver: scopeResolver,
127
+ scopes: new Map(),
128
+ handshakes: new Set(),
129
+ resources,
130
+ dispose
131
+ });
132
+
133
+ const plugin = new Carno({ exports: [] });
134
+ plugin.controllers(options.controllers);
135
+ plugin.services([LiveService]);
136
+
137
+ if (options.sse) {
138
+ const sse = new SseTransport({
139
+ heartbeatMs: config.sseHeartbeatMs,
140
+ maxConnections: config.sseMaxConnections,
141
+ onDisconnect: dropLiveConnection
142
+ });
143
+
144
+ fan.add(sse);
145
+ dispose.push(() => sse.stop());
146
+
147
+ const routes = createSseRoutes({
148
+ transport: sse,
149
+ streamPath: config.ssePath,
150
+ controlPath: config.sseControlPath
151
+ });
152
+
153
+ plugin.route('GET', routes.streamPath, routes.stream);
154
+ plugin.route('POST', routes.controlPath, routes.control);
155
+ }
156
+
157
+ let teachEtag: ((paths: { method: string; path: string; resourceId?: string }[]) => void) | null = null;
158
+
159
+ // Registered now, taught later: `plugin.middlewares()` runs before
160
+ // bootstrap, and the resources are only known inside the builder.
161
+ // The middleware also gates polling, even when ETags are disabled.
162
+ const etag = new LiveETagMiddleware([], { enabled: options.etag !== false });
163
+ etag.setPollingGuard(async ({ resourceId, inputs, request }) => {
164
+ const connectionId = request.headers.get(LIVE_CONNECTION_HEADER);
165
+
166
+ if (!connectionId) {
167
+ return null;
168
+ }
169
+
170
+ let scope;
171
+
172
+ try {
173
+ scope = await scopeResolver.resolve({
174
+ connectionId,
175
+ token: request.headers.get(LIVE_TOKEN_HEADER) ?? undefined
176
+ });
177
+ } catch {
178
+ return null;
179
+ }
180
+
181
+ return await engine.authorizePolling(connectionId, resourceId, inputs, scope)
182
+ ? scope
183
+ : null;
184
+ });
185
+ plugin.middlewares([etag]);
186
+ teachEtag = paths => etag.setPaths(paths);
187
+
188
+ const websocket = WebSocketPlugin.create(
189
+ [LiveGateway, ...(options.gateways ?? [])],
190
+ options.websocket
191
+ );
192
+
193
+ const innerBuilder = websocket._wsHandlerBuilder!;
194
+ const upgradePaths = [...websocket._wsUpgradePaths];
195
+
196
+ plugin.use(websocket);
197
+
198
+ // The builder runs after bootstrap, when the container holds the
199
+ // controller instances and the ORM holds its connection — which is why
200
+ // everything that needs a database URL is started here and not above.
201
+ plugin.wsHandler((container: Container) => {
202
+ // Resolved here and not above: the container does not exist until
203
+ // bootstrap, and an app with no observability plugin never
204
+ // registers one.
205
+ sink = container.has(ObservabilityService) ? container.get(ObservabilityService) : null;
206
+ const routeExecutor = createLiveRouteExecutor(container.get(Carno));
207
+
208
+ for (const ControllerClass of options.controllers) {
209
+ resources.register(ControllerClass, container.get(ControllerClass), routeExecutor);
210
+ }
211
+
212
+ teachEtag?.(resources.livePaths());
213
+
214
+ // Both halves of the per-connection trap are known only here: the
215
+ // resolver comes from the options, the resources from the scan the
216
+ // loop above just finished.
217
+ const scopeWarning = defaultScopeWarning({
218
+ privateResourceIds: resources.idsShared('private'),
219
+ usingDefaultResolver: options.scopeResolver === undefined,
220
+ maxInstancesPerNode: config.maxInstancesPerNode
221
+ });
222
+
223
+ if (scopeWarning) {
224
+ console.warn(scopeWarning);
225
+ }
226
+
227
+ if (options.pgNotify) {
228
+ const driver = Orm.getInstance().driverInstance;
229
+ const deliver = (events: InvalidationEvent[]): void => {
230
+ // A trigger already notified every node. Publishing it on
231
+ // the bus would send it around a second time.
232
+ if (distributedBus) {
233
+ distributedBus.publishLocal(events);
234
+ return;
235
+ }
236
+
237
+ bus.publish(events);
238
+ };
239
+
240
+ const pgEmitter = new PgNotifyEmitter(deliver, {
241
+ tables: options.pgNotify.tables,
242
+ url: options.pgNotify.url ?? driver.connectionString,
243
+ channel: options.pgNotify.channel,
244
+ execute: sql => driver.executeSql(sql)
245
+ });
246
+
247
+ // Two emitters on one table would wake the same instance twice.
248
+ emitter.setCoveredTables(pgEmitter.coveredTables());
249
+ dispose.push(() => pgEmitter.detach());
250
+
251
+ void pgEmitter.attach().catch(error => {
252
+ console.error('[carno:live] the Postgres emitter failed to attach', error);
253
+ });
254
+ }
255
+
256
+ if (distributedBus) {
257
+ if (!options.distributed?.url) {
258
+ distributedBus.setUrl(Orm.getInstance().driverInstance.connectionString);
259
+ }
260
+
261
+ void distributedBus.start().catch(error => {
262
+ console.error('[carno:live] the distributed bus failed to start', error);
263
+ });
264
+
265
+ dispose.push(() => distributedBus.stop());
266
+ }
267
+
268
+ emitter.attach();
269
+ engine.start();
270
+
271
+ return innerBuilder(container);
272
+ }, upgradePaths);
273
+
274
+ return plugin;
275
+ }
276
+ }