@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,31 @@
|
|
|
1
|
+
const FNV_OFFSET_BASIS_A = 2166136261;
|
|
2
|
+
const FNV_OFFSET_BASIS_B = 0x9e3779b9;
|
|
3
|
+
const FNV_PRIME = 16777619;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FNV-1a widened to 64 output bits by running two lanes with different offset
|
|
7
|
+
* bases, the second one position-sensitive.
|
|
8
|
+
*
|
|
9
|
+
* `packages/orm/src/cache/cache-key-generator.ts` uses the 32-bit variant,
|
|
10
|
+
* which is right for a cache — a collision there costs one stale entry. Here a
|
|
11
|
+
* content-hash collision means "data changed but no patch was sent", and 32
|
|
12
|
+
* bits reach 50% collision odds around 65k keys while `maxInstancesPerNode`
|
|
13
|
+
* alone is 50000. This lives in one module so it can be swapped for a stronger
|
|
14
|
+
* hash without touching anything else.
|
|
15
|
+
*/
|
|
16
|
+
export function fnv1a64(input: string): string {
|
|
17
|
+
let laneA = FNV_OFFSET_BASIS_A;
|
|
18
|
+
let laneB = FNV_OFFSET_BASIS_B;
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < input.length; i++) {
|
|
21
|
+
const code = input.charCodeAt(i);
|
|
22
|
+
|
|
23
|
+
laneA ^= code;
|
|
24
|
+
laneA = Math.imul(laneA, FNV_PRIME);
|
|
25
|
+
|
|
26
|
+
laneB ^= code + i;
|
|
27
|
+
laneB = Math.imul(laneB, FNV_PRIME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (laneA >>> 0).toString(16).padStart(8, '0') + (laneB >>> 0).toString(16).padStart(8, '0');
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Everything a resource compute is allowed to read from the caller. */
|
|
2
|
+
export interface LiveInputs {
|
|
3
|
+
params: Record<string, string>;
|
|
4
|
+
query: Record<string, string | string[]>;
|
|
5
|
+
/**
|
|
6
|
+
* Body of a @Post() live resource. Absent on @Get().
|
|
7
|
+
*
|
|
8
|
+
* It is part of the instance identity, not extra baggage: two clients
|
|
9
|
+
* posting different filters must not share one computed instance.
|
|
10
|
+
*/
|
|
11
|
+
body?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Ambient dimensions resolved on the server; never sent by the client. */
|
|
15
|
+
export interface LiveScope {
|
|
16
|
+
principal?: string | number;
|
|
17
|
+
tenant?: string | number;
|
|
18
|
+
/** Optional request headers for replaying route middleware during compute. */
|
|
19
|
+
headers?: HeadersInit;
|
|
20
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { PatchOp } from '../patch/types';
|
|
2
|
+
import type { LiveInputs } from '../resource/types';
|
|
3
|
+
|
|
4
|
+
export const LIVE_PROTOCOL_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/** Headers used by the HTTP polling rung to carry the live auth contract. */
|
|
7
|
+
export const LIVE_POLL_HEADER = 'X-Carno-Live-Poll';
|
|
8
|
+
export const LIVE_CONNECTION_HEADER = 'X-Carno-Live-Connection';
|
|
9
|
+
export const LIVE_RESOURCE_HEADER = 'X-Carno-Live-Resource';
|
|
10
|
+
export const LIVE_TOKEN_HEADER = 'X-Carno-Live-Token';
|
|
11
|
+
|
|
12
|
+
/** Sent once per connection, before any subscription. */
|
|
13
|
+
export interface ClientHello {
|
|
14
|
+
t: 'hello';
|
|
15
|
+
v: number;
|
|
16
|
+
/** Opaque credential; the server's LiveScopeResolver interprets it. */
|
|
17
|
+
token?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClientSub {
|
|
21
|
+
t: 'sub';
|
|
22
|
+
/** Client-chosen subscription id. The instance id never leaves the server. */
|
|
23
|
+
sid: string;
|
|
24
|
+
resource: string;
|
|
25
|
+
inputs: LiveInputs;
|
|
26
|
+
/** Content hash of what the client already has on screen, if anything. */
|
|
27
|
+
hash?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClientUnsub {
|
|
31
|
+
t: 'unsub';
|
|
32
|
+
sid: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ClientResync {
|
|
36
|
+
t: 'resync';
|
|
37
|
+
sid: string;
|
|
38
|
+
hash?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ClientMessage = ClientHello | ClientSub | ClientUnsub | ClientResync;
|
|
42
|
+
|
|
43
|
+
/** Full state. */
|
|
44
|
+
export interface ServerSnapshot {
|
|
45
|
+
t: 'snapshot';
|
|
46
|
+
sid: string;
|
|
47
|
+
rev: number;
|
|
48
|
+
hash: string;
|
|
49
|
+
data: unknown;
|
|
50
|
+
/**
|
|
51
|
+
* The resource's `@Live({ key })`, if it declared one. The client needs it
|
|
52
|
+
* to apply keyed ops, and this is the only message that establishes state,
|
|
53
|
+
* so it is the only place it has to travel.
|
|
54
|
+
*/
|
|
55
|
+
key?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The client's hash matched what the server computed: nothing on the wire. */
|
|
59
|
+
export interface ServerCurrent {
|
|
60
|
+
t: 'current';
|
|
61
|
+
sid: string;
|
|
62
|
+
rev: number;
|
|
63
|
+
hash: string;
|
|
64
|
+
/** Same reason as on `snapshot`: this also establishes state, on hydration. */
|
|
65
|
+
key?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ServerPatch {
|
|
69
|
+
t: 'patch';
|
|
70
|
+
sid: string;
|
|
71
|
+
from: number;
|
|
72
|
+
to: number;
|
|
73
|
+
hash: string;
|
|
74
|
+
ops: PatchOp[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The server cannot vouch for this instance being current (a recompute is
|
|
79
|
+
* failing). The client keeps showing the last data and flags it, so the UI can
|
|
80
|
+
* say so.
|
|
81
|
+
*/
|
|
82
|
+
export interface ServerStale {
|
|
83
|
+
t: 'stale';
|
|
84
|
+
sid: string;
|
|
85
|
+
reason: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The subscription is invalid or not allowed; the client ends the instance. */
|
|
89
|
+
export interface ServerError {
|
|
90
|
+
t: 'error';
|
|
91
|
+
sid: string;
|
|
92
|
+
code: string;
|
|
93
|
+
message: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ServerMessage = ServerSnapshot | ServerCurrent | ServerPatch | ServerStale | ServerError;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { LiveTransport } from '../LiveEngine';
|
|
2
|
+
import type { ServerMessage } from '../shared/protocol';
|
|
3
|
+
|
|
4
|
+
export interface OwnedTransport extends LiveTransport {
|
|
5
|
+
/** Whether this transport is the one holding that connection. */
|
|
6
|
+
owns(connectionId: string): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One engine, several pipes.
|
|
11
|
+
*
|
|
12
|
+
* The engine addresses connections by an opaque id and never asks how they are
|
|
13
|
+
* reached, which is exactly what makes SSE a transport rather than a second
|
|
14
|
+
* engine. This routes each send to whichever transport claims the id.
|
|
15
|
+
*/
|
|
16
|
+
export class FanTransport implements LiveTransport {
|
|
17
|
+
private readonly children: OwnedTransport[] = [];
|
|
18
|
+
|
|
19
|
+
add(child: OwnedTransport): void {
|
|
20
|
+
this.children.push(child);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
send(connectionId: string, message: ServerMessage): number {
|
|
24
|
+
for (const child of this.children) {
|
|
25
|
+
if (child.owns(connectionId)) {
|
|
26
|
+
return child.send(connectionId, message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Nobody holds it any more. Zero is "dropped", which the engine already
|
|
31
|
+
// handles; throwing here would take a whole fan-out down with it.
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CarnoSocket, Gateway, OnClose, OnMessage, OnOpen } from '@carno.js/websocket';
|
|
2
|
+
import { getLiveRuntime } from '../runtime';
|
|
3
|
+
import type { ClientMessage } from '../shared/protocol';
|
|
4
|
+
|
|
5
|
+
export const LIVE_GATEWAY_PATH = '/live';
|
|
6
|
+
|
|
7
|
+
/** Per-connection tail so `hello` finishes before a back-to-back `sub`. */
|
|
8
|
+
const inbound = new Map<string, Promise<void>>();
|
|
9
|
+
|
|
10
|
+
@Gateway(LIVE_GATEWAY_PATH)
|
|
11
|
+
export class LiveGateway {
|
|
12
|
+
@OnOpen()
|
|
13
|
+
onOpen(socket: CarnoSocket): void {
|
|
14
|
+
const runtime = getLiveRuntime();
|
|
15
|
+
runtime.transport.add(socket);
|
|
16
|
+
runtime.handshakes.delete(socket.id);
|
|
17
|
+
// Until a `hello` arrives, the connection is its own principal: safe,
|
|
18
|
+
// shares nothing.
|
|
19
|
+
runtime.scopes.set(socket.id, { principal: socket.id });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@OnMessage()
|
|
23
|
+
onMessage(socket: CarnoSocket, raw: string | ArrayBuffer | Uint8Array): void {
|
|
24
|
+
if (typeof raw !== 'string') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
void handleMessage(socket.id, raw).catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@OnClose()
|
|
32
|
+
onClose(socket: CarnoSocket): void {
|
|
33
|
+
dropLiveConnection(socket.id);
|
|
34
|
+
getLiveRuntime().transport.remove(socket.id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tear down a connection from any pipe.
|
|
40
|
+
*
|
|
41
|
+
* WebSocket and SSE must leave the engine, the inbound queue and the scope
|
|
42
|
+
* map in the same state: an in-flight `hello` cannot subscribe after the
|
|
43
|
+
* client has gone, and a cancelled stream cannot leak a principal.
|
|
44
|
+
*/
|
|
45
|
+
export function dropLiveConnection(connectionId: string): void {
|
|
46
|
+
inbound.delete(connectionId);
|
|
47
|
+
const runtime = getLiveRuntime();
|
|
48
|
+
runtime.engine.dropConnection(connectionId);
|
|
49
|
+
runtime.scopes.delete(connectionId);
|
|
50
|
+
runtime.handshakes.delete(connectionId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function handleMessage(connectionId: string, raw: string): Promise<void> {
|
|
54
|
+
const previous = inbound.get(connectionId) ?? Promise.resolve();
|
|
55
|
+
const next = previous.then(
|
|
56
|
+
() => dispatch(connectionId, raw),
|
|
57
|
+
() => dispatch(connectionId, raw)
|
|
58
|
+
);
|
|
59
|
+
inbound.set(connectionId, next);
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function dispatch(connectionId: string, raw: string): Promise<void> {
|
|
64
|
+
if (!inbound.has(connectionId)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const runtime = getLiveRuntime();
|
|
69
|
+
|
|
70
|
+
let message: ClientMessage;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
message = JSON.parse(raw) as ClientMessage;
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!message || typeof (message as { t?: unknown }).t !== 'string') {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
switch (message.t) {
|
|
83
|
+
case 'hello': {
|
|
84
|
+
if (runtime.handshakes.has(connectionId)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const scope = await runtime.resolver.resolve({ connectionId, token: message.token });
|
|
89
|
+
|
|
90
|
+
if (!inbound.has(connectionId)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
runtime.scopes.set(connectionId, scope);
|
|
95
|
+
runtime.handshakes.add(connectionId);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case 'sub': {
|
|
100
|
+
const scope = runtime.scopes.get(connectionId) ?? { principal: connectionId };
|
|
101
|
+
await runtime.engine.subscribe(
|
|
102
|
+
connectionId,
|
|
103
|
+
message.sid,
|
|
104
|
+
message.resource,
|
|
105
|
+
{
|
|
106
|
+
params: message.inputs?.params ?? {},
|
|
107
|
+
query: message.inputs?.query ?? {},
|
|
108
|
+
body: message.inputs?.body
|
|
109
|
+
},
|
|
110
|
+
scope,
|
|
111
|
+
message.hash
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'unsub':
|
|
117
|
+
runtime.engine.unsubscribe(connectionId, message.sid);
|
|
118
|
+
return;
|
|
119
|
+
|
|
120
|
+
case 'resync':
|
|
121
|
+
await runtime.engine.resync(connectionId, message.sid, message.hash);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CarnoSocket } from '@carno.js/websocket';
|
|
2
|
+
import type { ServerMessage } from '../shared/protocol';
|
|
3
|
+
import type { OwnedTransport } from './FanTransport';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sends protocol messages over the raw socket.
|
|
7
|
+
*
|
|
8
|
+
* We use `socket.send()` rather than `socket.emit()` because emit wraps the
|
|
9
|
+
* payload in `{ event, data }` for the gateway's own event protocol, and this
|
|
10
|
+
* is a different protocol.
|
|
11
|
+
*/
|
|
12
|
+
export class SocketTransport implements OwnedTransport {
|
|
13
|
+
private readonly sockets = new Map<string, CarnoSocket>();
|
|
14
|
+
|
|
15
|
+
add(socket: CarnoSocket): void {
|
|
16
|
+
this.sockets.set(socket.id, socket);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
remove(connectionId: string): void {
|
|
20
|
+
this.sockets.delete(connectionId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
owns(connectionId: string): boolean {
|
|
24
|
+
return this.sockets.has(connectionId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** <= 0 means back-pressured or dropped; the engine counts those. */
|
|
28
|
+
send(connectionId: string, message: ServerMessage): number {
|
|
29
|
+
const socket = this.sockets.get(connectionId);
|
|
30
|
+
|
|
31
|
+
if (!socket) {
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return socket.send(JSON.stringify(message));
|
|
37
|
+
} catch {
|
|
38
|
+
// The socket closed between fan-out and send. Treat as dropped;
|
|
39
|
+
// the close handler will clean it up.
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ServerMessage } from '../shared/protocol';
|
|
2
|
+
import type { OwnedTransport } from './FanTransport';
|
|
3
|
+
|
|
4
|
+
export interface SseTransportOptions {
|
|
5
|
+
/** 0 disables the heartbeat. Only tests want that. */
|
|
6
|
+
heartbeatMs: number;
|
|
7
|
+
maxConnections: number;
|
|
8
|
+
/** Called when the client goes away, so the engine can drop the connection. */
|
|
9
|
+
onDisconnect?: (connectionId: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ENCODER = new TextEncoder();
|
|
13
|
+
const NativeReadableStream = ReadableStream;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The downstream half of the SSE transport.
|
|
17
|
+
*
|
|
18
|
+
* Upstream is `POST /live/control`, which speaks the same protocol into the
|
|
19
|
+
* same handler the WebSocket gateway uses -- see `sse-routes.ts`. This half
|
|
20
|
+
* only writes frames, so the engine cannot tell it apart from a socket.
|
|
21
|
+
*/
|
|
22
|
+
export class SseTransport implements OwnedTransport {
|
|
23
|
+
private readonly streams = new Map<string, ReadableStreamDefaultController<Uint8Array>>();
|
|
24
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(private readonly options: SseTransportOptions) {}
|
|
27
|
+
|
|
28
|
+
open(connectionId: string): ReadableStream<Uint8Array> {
|
|
29
|
+
if (this.streams.size >= this.options.maxConnections) {
|
|
30
|
+
throw new Error(`[carno:live] the SSE transport is at capacity (${this.options.maxConnections}).`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new NativeReadableStream<Uint8Array>({
|
|
34
|
+
start: controller => {
|
|
35
|
+
this.streams.set(connectionId, controller);
|
|
36
|
+
// The client cannot learn its own connection id any other way,
|
|
37
|
+
// and it needs it to address the control endpoint.
|
|
38
|
+
this.write(controller, `data: ${JSON.stringify({ t: 'ready', cid: connectionId })}\n\n`);
|
|
39
|
+
this.ensureHeartbeat();
|
|
40
|
+
},
|
|
41
|
+
cancel: () => {
|
|
42
|
+
this.streams.delete(connectionId);
|
|
43
|
+
this.options.onDisconnect?.(connectionId);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
owns(connectionId: string): boolean {
|
|
49
|
+
return this.streams.has(connectionId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
send(connectionId: string, message: ServerMessage): number {
|
|
53
|
+
const controller = this.streams.get(connectionId);
|
|
54
|
+
|
|
55
|
+
if (!controller) {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return this.write(controller, `data: ${JSON.stringify(message)}\n\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
close(connectionId: string): void {
|
|
63
|
+
const controller = this.streams.get(connectionId);
|
|
64
|
+
this.streams.delete(connectionId);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
controller?.close();
|
|
68
|
+
} catch {
|
|
69
|
+
// Already closed from the other end.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
count(): number {
|
|
74
|
+
return this.streams.size;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop(): void {
|
|
78
|
+
if (this.heartbeat) {
|
|
79
|
+
clearInterval(this.heartbeat);
|
|
80
|
+
this.heartbeat = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const connectionId of [...this.streams.keys()]) {
|
|
84
|
+
this.close(connectionId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private ensureHeartbeat(): void {
|
|
89
|
+
if (this.heartbeat || this.options.heartbeatMs <= 0) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A comment frame. EventSource ignores it; an idle-timeout proxy does
|
|
94
|
+
// not, which is the whole point.
|
|
95
|
+
this.heartbeat = setInterval(() => {
|
|
96
|
+
for (const controller of this.streams.values()) {
|
|
97
|
+
this.write(controller, ': ping\n\n');
|
|
98
|
+
}
|
|
99
|
+
}, this.options.heartbeatMs);
|
|
100
|
+
|
|
101
|
+
this.heartbeat.unref?.();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private write(controller: ReadableStreamDefaultController<Uint8Array>, frame: string): number {
|
|
105
|
+
try {
|
|
106
|
+
controller.enqueue(ENCODER.encode(frame));
|
|
107
|
+
return 1;
|
|
108
|
+
} catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { LiveScope } from '../shared/inputs';
|
|
2
|
+
|
|
3
|
+
export interface LiveHandshake {
|
|
4
|
+
connectionId: string;
|
|
5
|
+
/** Opaque credential from the client's `hello`. */
|
|
6
|
+
token?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LiveScopeResolver {
|
|
10
|
+
resolve(handshake: LiveHandshake): LiveScope | Promise<LiveScope>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default resolver: every connection is its own principal.
|
|
15
|
+
*
|
|
16
|
+
* Safe by construction — nothing is ever shared between connections, so no
|
|
17
|
+
* application can leak one user's data to another by forgetting to configure
|
|
18
|
+
* this. Applications that want `shared: 'tenant'` or a real user identity
|
|
19
|
+
* replace it.
|
|
20
|
+
*/
|
|
21
|
+
export class ConnectionScopeResolver implements LiveScopeResolver {
|
|
22
|
+
resolve(handshake: LiveHandshake): LiveScope {
|
|
23
|
+
return { principal: handshake.connectionId };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getLiveRuntime } from '../runtime';
|
|
2
|
+
import { dropLiveConnection, handleMessage } from './LiveGateway';
|
|
3
|
+
import type { SseTransport } from './SseTransport';
|
|
4
|
+
|
|
5
|
+
export interface SseRouteOptions {
|
|
6
|
+
transport: SseTransport;
|
|
7
|
+
streamPath: string;
|
|
8
|
+
controlPath: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Captured at load so a later happy-dom register cannot replace the
|
|
12
|
+
// constructor Bun.serve requires of a route handler.
|
|
13
|
+
const NativeResponse = Response;
|
|
14
|
+
|
|
15
|
+
const SSE_HEADERS = {
|
|
16
|
+
'Content-Type': 'text/event-stream',
|
|
17
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
18
|
+
Connection: 'keep-alive',
|
|
19
|
+
// Nginx buffers proxied responses by default, which turns a live stream
|
|
20
|
+
// into a stream that arrives all at once, at the end.
|
|
21
|
+
'X-Accel-Buffering': 'no'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The two halves of the SSE transport, as HTTP.
|
|
26
|
+
*
|
|
27
|
+
* `GET streamPath` opens the downstream and names the connection; every
|
|
28
|
+
* client message goes up through `POST controlPath` and into the same
|
|
29
|
+
* `handleMessage` the WebSocket gateway uses. There is no second protocol
|
|
30
|
+
* here, and there must never be one.
|
|
31
|
+
*
|
|
32
|
+
* Handlers registered through `Carno.route()` receive a Bun `Request`, not a
|
|
33
|
+
* `Context` -- the docstring on that method says otherwise, the runtime does
|
|
34
|
+
* not.
|
|
35
|
+
*/
|
|
36
|
+
export function createSseRoutes(options: SseRouteOptions) {
|
|
37
|
+
const { transport, streamPath, controlPath } = options;
|
|
38
|
+
|
|
39
|
+
const stream = (request: Request): Response => {
|
|
40
|
+
// Unguessable on purpose: the id is a bearer for this connection, and
|
|
41
|
+
// whoever holds it can subscribe as it.
|
|
42
|
+
const connectionId = `sse:${crypto.randomUUID()}`;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const body = transport.open(connectionId);
|
|
46
|
+
const runtime = getLiveRuntime();
|
|
47
|
+
// Until a `hello` arrives, the connection is its own principal:
|
|
48
|
+
// safe, shares nothing. Same rule as the gateway's onOpen.
|
|
49
|
+
runtime.scopes.set(connectionId, { principal: connectionId });
|
|
50
|
+
runtime.handshakes.delete(connectionId);
|
|
51
|
+
|
|
52
|
+
// Cancelling the client reader does not always reach the stream's
|
|
53
|
+
// `cancel`; aborting the request does, and is what a closed
|
|
54
|
+
// EventSource looks like on the wire.
|
|
55
|
+
request.signal.addEventListener('abort', () => {
|
|
56
|
+
transport.close(connectionId);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
dropLiveConnection(connectionId);
|
|
60
|
+
} catch {
|
|
61
|
+
// closeLiveRuntime nulls the runtime before dispose.
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return new NativeResponse(body, { status: 200, headers: SSE_HEADERS });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return new NativeResponse((error as Error).message, { status: 503 });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const control = async (request: Request): Promise<Response> => {
|
|
72
|
+
let payload: { cid?: unknown; message?: unknown };
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
payload = await request.json() as { cid?: unknown; message?: unknown };
|
|
76
|
+
} catch {
|
|
77
|
+
return new NativeResponse('malformed body', { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof payload.cid !== 'string' || !payload.message) {
|
|
81
|
+
return new NativeResponse('cid and message are required', { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!transport.owns(payload.cid)) {
|
|
85
|
+
return new NativeResponse('unknown connection', { status: 404 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await handleMessage(payload.cid, JSON.stringify(payload.message));
|
|
89
|
+
|
|
90
|
+
return new NativeResponse(null, { status: 204 });
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { streamPath, controlPath, stream, control };
|
|
94
|
+
}
|