@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.
Files changed (199) hide show
  1. package/LICENSE +21 -0
  2. package/dist/LiveEngine.d.ts +89 -0
  3. package/dist/LiveEngine.js +520 -0
  4. package/dist/LivePlugin.d.ts +55 -0
  5. package/dist/LivePlugin.js +165 -0
  6. package/dist/LiveService.d.ts +32 -0
  7. package/dist/LiveService.js +51 -0
  8. package/dist/auth/authorizer.d.ts +33 -0
  9. package/dist/auth/authorizer.js +32 -0
  10. package/dist/bus/InProcessBus.d.ts +8 -0
  11. package/dist/bus/InProcessBus.js +29 -0
  12. package/dist/bus/InvalidationBus.d.ts +15 -0
  13. package/dist/bus/InvalidationBus.js +2 -0
  14. package/dist/bus/PgNotifyBus.d.ts +55 -0
  15. package/dist/bus/PgNotifyBus.js +138 -0
  16. package/dist/client/angular.d.ts +19 -0
  17. package/dist/client/angular.js +71 -0
  18. package/dist/client/core.d.ts +111 -0
  19. package/dist/client/core.js +337 -0
  20. package/dist/client/hydrate.d.ts +19 -0
  21. package/dist/client/hydrate.js +65 -0
  22. package/dist/client/optimistic.d.ts +17 -0
  23. package/dist/client/optimistic.js +2 -0
  24. package/dist/client/react.d.ts +22 -0
  25. package/dist/client/react.js +69 -0
  26. package/dist/client/transport.d.ts +133 -0
  27. package/dist/client/transport.js +431 -0
  28. package/dist/client/vanilla.d.ts +54 -0
  29. package/dist/client/vanilla.js +111 -0
  30. package/dist/client/vue.d.ts +12 -0
  31. package/dist/client/vue.js +35 -0
  32. package/dist/config.d.ts +32 -0
  33. package/dist/config.js +21 -0
  34. package/dist/decorators/Live.d.ts +4 -0
  35. package/dist/decorators/Live.js +17 -0
  36. package/dist/emitters/AppEmitter.d.ts +20 -0
  37. package/dist/emitters/AppEmitter.js +59 -0
  38. package/dist/emitters/pg-listener.d.ts +50 -0
  39. package/dist/emitters/pg-listener.js +120 -0
  40. package/dist/emitters/pg-notify-emitter.d.ts +59 -0
  41. package/dist/emitters/pg-notify-emitter.js +105 -0
  42. package/dist/emitters/pg-trigger-sql.d.ts +27 -0
  43. package/dist/emitters/pg-trigger-sql.js +107 -0
  44. package/dist/emitters/statement-keys.d.ts +16 -0
  45. package/dist/emitters/statement-keys.js +182 -0
  46. package/dist/graph/DependencyGraph.d.ts +27 -0
  47. package/dist/graph/DependencyGraph.js +118 -0
  48. package/dist/graph/SubscriptionRegistry.d.ts +22 -0
  49. package/dist/graph/SubscriptionRegistry.js +92 -0
  50. package/dist/graph/dep-key.d.ts +14 -0
  51. package/dist/graph/dep-key.js +37 -0
  52. package/dist/graph/types.d.ts +13 -0
  53. package/dist/graph/types.js +2 -0
  54. package/dist/http/etag.d.ts +41 -0
  55. package/dist/http/etag.js +109 -0
  56. package/dist/index.d.ts +56 -0
  57. package/dist/index.js +111 -0
  58. package/dist/metadata.d.ts +16 -0
  59. package/dist/metadata.js +4 -0
  60. package/dist/observability.d.ts +25 -0
  61. package/dist/observability.js +44 -0
  62. package/dist/patch/PatchEngine.d.ts +20 -0
  63. package/dist/patch/PatchEngine.js +180 -0
  64. package/dist/patch/types.d.ts +33 -0
  65. package/dist/patch/types.js +2 -0
  66. package/dist/resource/ResourceRegistry.d.ts +32 -0
  67. package/dist/resource/ResourceRegistry.js +126 -0
  68. package/dist/resource/dependency-context.d.ts +25 -0
  69. package/dist/resource/dependency-context.js +45 -0
  70. package/dist/resource/instance-id.d.ts +17 -0
  71. package/dist/resource/instance-id.js +61 -0
  72. package/dist/resource/prefetch.d.ts +20 -0
  73. package/dist/resource/prefetch.js +31 -0
  74. package/dist/resource/route-executor.d.ts +13 -0
  75. package/dist/resource/route-executor.js +86 -0
  76. package/dist/resource/types.d.ts +26 -0
  77. package/dist/resource/types.js +2 -0
  78. package/dist/runtime.d.ts +41 -0
  79. package/dist/runtime.js +47 -0
  80. package/dist/shared/canonical.d.ts +14 -0
  81. package/dist/shared/canonical.js +60 -0
  82. package/dist/shared/descriptor.d.ts +44 -0
  83. package/dist/shared/descriptor.js +19 -0
  84. package/dist/shared/hash.d.ts +12 -0
  85. package/dist/shared/hash.js +29 -0
  86. package/dist/shared/inputs.d.ts +19 -0
  87. package/dist/shared/inputs.js +2 -0
  88. package/dist/shared/protocol.d.ts +83 -0
  89. package/dist/shared/protocol.js +9 -0
  90. package/dist/transport/FanTransport.d.ts +18 -0
  91. package/dist/transport/FanTransport.js +29 -0
  92. package/dist/transport/LiveGateway.d.ts +16 -0
  93. package/dist/transport/LiveGateway.js +126 -0
  94. package/dist/transport/SocketTransport.d.ts +18 -0
  95. package/dist/transport/SocketTransport.js +40 -0
  96. package/dist/transport/SseTransport.d.ts +30 -0
  97. package/dist/transport/SseTransport.js +92 -0
  98. package/dist/transport/scope-resolver.d.ts +20 -0
  99. package/dist/transport/scope-resolver.js +17 -0
  100. package/dist/transport/sse-routes.d.ts +24 -0
  101. package/dist/transport/sse-routes.js +78 -0
  102. package/package.json +92 -0
  103. package/src/LiveEngine.ts +730 -0
  104. package/src/LivePlugin.ts +253 -0
  105. package/src/LiveService.ts +47 -0
  106. package/src/auth/authorizer.ts +56 -0
  107. package/src/bus/InProcessBus.ts +28 -0
  108. package/src/bus/InvalidationBus.ts +17 -0
  109. package/src/bus/PgNotifyBus.ts +188 -0
  110. package/src/client/angular.ts +115 -0
  111. package/src/client/core.ts +492 -0
  112. package/src/client/hydrate.ts +80 -0
  113. package/src/client/optimistic.ts +19 -0
  114. package/src/client/react.ts +113 -0
  115. package/src/client/transport.ts +568 -0
  116. package/src/client/vanilla.ts +165 -0
  117. package/src/client/vue.ts +67 -0
  118. package/src/config.ts +49 -0
  119. package/src/decorators/Live.ts +16 -0
  120. package/src/emitters/AppEmitter.ts +69 -0
  121. package/src/emitters/pg-listener.ts +171 -0
  122. package/src/emitters/pg-notify-emitter.ts +143 -0
  123. package/src/emitters/pg-trigger-sql.ts +111 -0
  124. package/src/emitters/statement-keys.ts +229 -0
  125. package/src/graph/DependencyGraph.ts +147 -0
  126. package/src/graph/SubscriptionRegistry.ts +109 -0
  127. package/src/graph/dep-key.ts +41 -0
  128. package/src/graph/types.ts +15 -0
  129. package/src/http/etag.ts +144 -0
  130. package/src/index.ts +81 -0
  131. package/src/metadata.ts +21 -0
  132. package/src/observability.ts +54 -0
  133. package/src/patch/PatchEngine.ts +235 -0
  134. package/src/patch/types.ts +39 -0
  135. package/src/resource/ResourceRegistry.ts +178 -0
  136. package/src/resource/dependency-context.ts +48 -0
  137. package/src/resource/instance-id.ts +65 -0
  138. package/src/resource/prefetch.ts +49 -0
  139. package/src/resource/route-executor.ts +134 -0
  140. package/src/resource/types.ts +35 -0
  141. package/src/runtime.ts +69 -0
  142. package/src/shared/canonical.ts +63 -0
  143. package/src/shared/descriptor.ts +44 -0
  144. package/src/shared/hash.ts +31 -0
  145. package/src/shared/inputs.ts +20 -0
  146. package/src/shared/protocol.ts +96 -0
  147. package/src/transport/FanTransport.ts +34 -0
  148. package/src/transport/LiveGateway.ts +124 -0
  149. package/src/transport/SocketTransport.ts +43 -0
  150. package/src/transport/SseTransport.ts +112 -0
  151. package/src/transport/scope-resolver.ts +25 -0
  152. package/src/transport/sse-routes.ts +94 -0
  153. package/test/acceptance-fase-2.test.ts +248 -0
  154. package/test/acceptance-fase-3.test.ts +216 -0
  155. package/test/acceptance.test.ts +306 -0
  156. package/test/angular-adapter.test.ts +151 -0
  157. package/test/app-emitter.test.ts +112 -0
  158. package/test/authorization.test.ts +175 -0
  159. package/test/client-core.test.ts +287 -0
  160. package/test/client-transport.test.ts +154 -0
  161. package/test/dependency-graph.test.ts +83 -0
  162. package/test/etag.test.ts +193 -0
  163. package/test/fan-transport.test.ts +62 -0
  164. package/test/happydom.ts +24 -0
  165. package/test/inputs-body.test.ts +81 -0
  166. package/test/instance-id.test.ts +104 -0
  167. package/test/live-engine.test.ts +593 -0
  168. package/test/live-post.test.ts +81 -0
  169. package/test/metrics.test.ts +131 -0
  170. package/test/optimistic.test.ts +166 -0
  171. package/test/orm-integration.test.ts +75 -0
  172. package/test/patch-engine.test.ts +121 -0
  173. package/test/pg-bus.test.ts +162 -0
  174. package/test/pg-listen-probe.test.ts +56 -0
  175. package/test/pg-listener.test.ts +134 -0
  176. package/test/pg-notify-emitter.test.ts +113 -0
  177. package/test/pg-notify-integration.test.ts +88 -0
  178. package/test/polling-auth.test.ts +169 -0
  179. package/test/prefetch.test.ts +137 -0
  180. package/test/react-adapter.test.tsx +83 -0
  181. package/test/react-rerender.test.tsx +131 -0
  182. package/test/resource-registry-helper.ts +35 -0
  183. package/test/resource-registry.test.ts +144 -0
  184. package/test/route-pipeline.test.ts +139 -0
  185. package/test/shared.test.ts +53 -0
  186. package/test/sse-routes.test.ts +204 -0
  187. package/test/sse-transport.test.ts +91 -0
  188. package/test/statement-keys.test.ts +126 -0
  189. package/test/subscription-registry.test.ts +61 -0
  190. package/test/transport-ladder.test.ts +293 -0
  191. package/test/transport.test.ts +274 -0
  192. package/test/types/optimistic-types.ts +34 -0
  193. package/test/use-live-action.test.ts +127 -0
  194. package/test/use-live.test.ts +109 -0
  195. package/test/vanilla-adapter.test.ts +200 -0
  196. package/test/vue-adapter.test.ts +122 -0
  197. package/tsconfig.json +15 -0
  198. package/tsconfig.tsbuildinfo +1 -0
  199. 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
+ }