@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,133 @@
1
+ import type { LiveSocket } from './core';
2
+ export interface TransportHandlers {
3
+ onOpen(): void;
4
+ onMessage(raw: string): void;
5
+ onClose(): void;
6
+ }
7
+ /**
8
+ * A pipe the client can speak the protocol over.
9
+ *
10
+ * The protocol does not change between implementations, and neither does the
11
+ * client's behaviour: a component cannot tell which one is in use, and the day
12
+ * an `if (kind === 'sse')` appears in a component, the abstraction has failed.
13
+ * `kind` exists to be logged.
14
+ */
15
+ export interface ClientTransport {
16
+ readonly kind: 'websocket' | 'sse' | 'polling';
17
+ start(handlers: TransportHandlers): void;
18
+ send(raw: string): void;
19
+ close(): void;
20
+ }
21
+ export declare class WebSocketTransport implements ClientTransport {
22
+ private readonly url;
23
+ private readonly factory;
24
+ readonly kind: "websocket";
25
+ private socket;
26
+ private closed;
27
+ constructor(url: string, factory?: (url: string) => LiveSocket);
28
+ start(handlers: TransportHandlers): void;
29
+ send(raw: string): void;
30
+ close(): void;
31
+ private report;
32
+ }
33
+ export interface RoutePath {
34
+ method: string;
35
+ path: string;
36
+ }
37
+ export interface EventSourceLike {
38
+ onopen: ((event: unknown) => void) | null;
39
+ onmessage: ((event: {
40
+ data: string;
41
+ }) => void) | null;
42
+ onerror: ((event: unknown) => void) | null;
43
+ close(): void;
44
+ }
45
+ /**
46
+ * Flatten the tree `@carno.js/client` generates into `resourceId -> path`.
47
+ *
48
+ * Polling needs a URL and the protocol carries a resource id, so without this
49
+ * the bottom rung cannot exist. Routes with no `@Live()` are skipped: polling
50
+ * one would be polling something nobody can subscribe to.
51
+ */
52
+ export declare function routeIndex(routes: unknown): Record<string, RoutePath>;
53
+ /**
54
+ * Try each rung in order, descend when one does not open.
55
+ *
56
+ * There is no promotion back up: a proxy that blocks WebSocket will keep
57
+ * blocking it, and retrying the top rung on every reconnect spends a round
58
+ * trip per cycle to fail forever. The next page load starts at the top again.
59
+ */
60
+ export declare class LadderTransport implements ClientTransport {
61
+ private readonly rungs;
62
+ private readonly options;
63
+ private active;
64
+ private handlers;
65
+ private rung;
66
+ private settled;
67
+ private probe;
68
+ private closed;
69
+ constructor(rungs: (() => ClientTransport)[], options: {
70
+ probeMs: number;
71
+ });
72
+ get kind(): ClientTransport['kind'];
73
+ start(handlers: TransportHandlers): void;
74
+ send(raw: string): void;
75
+ close(): void;
76
+ private tryRung;
77
+ private descend;
78
+ private clearProbe;
79
+ }
80
+ /**
81
+ * The protocol over Server-Sent Events: down the stream, up by POST.
82
+ *
83
+ * The connection id arrives in the first frame and every client message
84
+ * carries it, because the control endpoint has no other way to know which
85
+ * stream a POST belongs to.
86
+ */
87
+ export declare class SseClientTransport implements ClientTransport {
88
+ private readonly baseUrl;
89
+ private readonly options;
90
+ readonly kind: "sse";
91
+ private source;
92
+ private cid;
93
+ private queue;
94
+ private closed;
95
+ constructor(baseUrl: string, options?: {
96
+ streamPath?: string;
97
+ controlPath?: string;
98
+ fetch?: typeof fetch;
99
+ eventSourceFactory?: (url: string) => EventSourceLike;
100
+ });
101
+ start(handlers: TransportHandlers): void;
102
+ send(raw: string): void;
103
+ close(): void;
104
+ }
105
+ /**
106
+ * The floor: conditional GET on the route the resource already serves.
107
+ *
108
+ * There are no patches here, only snapshots and 304s. A patch would need the
109
+ * server to remember this client's previous revision, which is precisely the
110
+ * history §8.1 says does not exist.
111
+ */
112
+ export declare class PollingTransport implements ClientTransport {
113
+ private readonly baseUrl;
114
+ private readonly routes;
115
+ private readonly options;
116
+ readonly kind: "polling";
117
+ private handlers;
118
+ private readonly polls;
119
+ private readonly connectionId;
120
+ private token;
121
+ private timer;
122
+ constructor(baseUrl: string, routes: Record<string, RoutePath>, options?: {
123
+ intervalMs?: number;
124
+ fetch?: typeof fetch;
125
+ token?: string;
126
+ });
127
+ start(handlers: TransportHandlers): void;
128
+ send(raw: string): void;
129
+ close(): void;
130
+ private tick;
131
+ private fetchOne;
132
+ private emit;
133
+ }
@@ -0,0 +1,431 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PollingTransport = exports.SseClientTransport = exports.LadderTransport = exports.WebSocketTransport = void 0;
4
+ exports.routeIndex = routeIndex;
5
+ const protocol_1 = require("../shared/protocol");
6
+ class WebSocketTransport {
7
+ constructor(url, factory = defaultSocketFactory) {
8
+ this.url = url;
9
+ this.factory = factory;
10
+ this.kind = 'websocket';
11
+ this.socket = null;
12
+ this.closed = false;
13
+ }
14
+ start(handlers) {
15
+ const socket = this.factory(this.url);
16
+ this.socket = socket;
17
+ socket.onopen = () => handlers.onOpen();
18
+ socket.onmessage = event => handlers.onMessage(event.data);
19
+ // An error and a close both mean the same thing here: the pipe is gone.
20
+ socket.onclose = () => this.report(handlers);
21
+ socket.onerror = () => this.report(handlers);
22
+ }
23
+ send(raw) {
24
+ this.socket?.send(raw);
25
+ }
26
+ close() {
27
+ this.closed = true;
28
+ this.socket?.close();
29
+ this.socket = null;
30
+ }
31
+ report(handlers) {
32
+ if (this.closed) {
33
+ // We closed it. Reporting it would schedule a reconnect to a
34
+ // client that has already given up.
35
+ // Also latches error-then-close so the ladder sees one death,
36
+ // not two descents that skip a rung.
37
+ return;
38
+ }
39
+ this.closed = true;
40
+ this.socket = null;
41
+ handlers.onClose();
42
+ }
43
+ }
44
+ exports.WebSocketTransport = WebSocketTransport;
45
+ function defaultSocketFactory(url) {
46
+ return new WebSocket(url);
47
+ }
48
+ /**
49
+ * Flatten the tree `@carno.js/client` generates into `resourceId -> path`.
50
+ *
51
+ * Polling needs a URL and the protocol carries a resource id, so without this
52
+ * the bottom rung cannot exist. Routes with no `@Live()` are skipped: polling
53
+ * one would be polling something nobody can subscribe to.
54
+ */
55
+ function routeIndex(routes) {
56
+ const index = {};
57
+ const walk = (node) => {
58
+ if (!node || typeof node !== 'object') {
59
+ return;
60
+ }
61
+ const candidate = node;
62
+ if (typeof candidate.method === 'string' && typeof candidate.path === 'string') {
63
+ if (candidate.method.toLowerCase() === 'get' &&
64
+ typeof candidate.resourceId === 'string' &&
65
+ candidate.live) {
66
+ index[candidate.resourceId] = { method: candidate.method, path: candidate.path };
67
+ }
68
+ return;
69
+ }
70
+ for (const value of Object.values(node)) {
71
+ walk(value);
72
+ }
73
+ };
74
+ walk(routes);
75
+ return index;
76
+ }
77
+ /**
78
+ * Try each rung in order, descend when one does not open.
79
+ *
80
+ * There is no promotion back up: a proxy that blocks WebSocket will keep
81
+ * blocking it, and retrying the top rung on every reconnect spends a round
82
+ * trip per cycle to fail forever. The next page load starts at the top again.
83
+ */
84
+ class LadderTransport {
85
+ constructor(rungs, options) {
86
+ this.rungs = rungs;
87
+ this.options = options;
88
+ this.active = null;
89
+ this.handlers = null;
90
+ this.rung = 0;
91
+ this.settled = false;
92
+ this.probe = null;
93
+ this.closed = false;
94
+ }
95
+ get kind() {
96
+ return this.active?.kind ?? 'websocket';
97
+ }
98
+ start(handlers) {
99
+ this.handlers = handlers;
100
+ this.rung = 0;
101
+ this.settled = false;
102
+ this.tryRung();
103
+ }
104
+ send(raw) {
105
+ this.active?.send(raw);
106
+ }
107
+ close() {
108
+ this.closed = true;
109
+ this.clearProbe();
110
+ this.active?.close();
111
+ this.active = null;
112
+ }
113
+ tryRung() {
114
+ this.clearProbe();
115
+ if (this.closed) {
116
+ return;
117
+ }
118
+ if (this.rung >= this.rungs.length) {
119
+ // Every rung refused. One close, not one per rung, or the client
120
+ // schedules a reconnect storm against itself.
121
+ this.handlers?.onClose();
122
+ return;
123
+ }
124
+ const rung = this.rung;
125
+ const transport = this.rungs[rung]();
126
+ this.active = transport;
127
+ transport.start({
128
+ onOpen: () => {
129
+ this.clearProbe();
130
+ this.settled = true;
131
+ this.handlers?.onOpen();
132
+ },
133
+ onMessage: raw => this.handlers?.onMessage(raw),
134
+ onClose: () => {
135
+ if (this.active !== transport) {
136
+ // A previous rung firing error then close. One death,
137
+ // one descent — otherwise we skip the rung that just
138
+ // became active.
139
+ return;
140
+ }
141
+ if (this.settled) {
142
+ // It worked and then dropped. That is an ordinary
143
+ // disconnect and the client's backoff owns it.
144
+ this.handlers?.onClose();
145
+ return;
146
+ }
147
+ this.descend();
148
+ }
149
+ });
150
+ // A proxy that swallows the upgrade without answering never fires an
151
+ // error. Without this the ladder would wait for a close that never
152
+ // comes, on the rung that is exactly the one being blocked.
153
+ if (this.active === transport && this.rung === rung && !this.settled && !this.closed) {
154
+ this.probe = setTimeout(() => this.descend(), this.options.probeMs);
155
+ this.probe.unref?.();
156
+ }
157
+ }
158
+ descend() {
159
+ if (this.settled || this.closed) {
160
+ return;
161
+ }
162
+ this.clearProbe();
163
+ this.active?.close();
164
+ this.active = null;
165
+ this.rung += 1;
166
+ this.tryRung();
167
+ }
168
+ clearProbe() {
169
+ if (this.probe) {
170
+ clearTimeout(this.probe);
171
+ this.probe = null;
172
+ }
173
+ }
174
+ }
175
+ exports.LadderTransport = LadderTransport;
176
+ /**
177
+ * The protocol over Server-Sent Events: down the stream, up by POST.
178
+ *
179
+ * The connection id arrives in the first frame and every client message
180
+ * carries it, because the control endpoint has no other way to know which
181
+ * stream a POST belongs to.
182
+ */
183
+ class SseClientTransport {
184
+ constructor(baseUrl, options = {}) {
185
+ this.baseUrl = baseUrl;
186
+ this.options = options;
187
+ this.kind = 'sse';
188
+ this.source = null;
189
+ this.cid = null;
190
+ this.queue = [];
191
+ this.closed = false;
192
+ }
193
+ start(handlers) {
194
+ const streamUrl = `${this.baseUrl}${this.options.streamPath ?? '/live/sse'}`;
195
+ if (!this.options.eventSourceFactory && typeof EventSource === 'undefined') {
196
+ // SSR and Bun do not provide the browser EventSource API. Treat
197
+ // that as an unavailable rung so the ladder can continue to the
198
+ // next transport instead of throwing during construction.
199
+ handlers.onClose();
200
+ return;
201
+ }
202
+ const build = this.options.eventSourceFactory
203
+ ?? ((url) => new EventSource(url));
204
+ const source = build(streamUrl);
205
+ this.source = source;
206
+ source.onmessage = event => {
207
+ let frame;
208
+ try {
209
+ frame = JSON.parse(event.data);
210
+ }
211
+ catch {
212
+ return;
213
+ }
214
+ if (frame.t === 'ready' && typeof frame.cid === 'string') {
215
+ this.cid = frame.cid;
216
+ handlers.onOpen();
217
+ // Anything the client tried to say before the id arrived.
218
+ const queued = this.queue;
219
+ this.queue = [];
220
+ for (const raw of queued) {
221
+ this.send(raw);
222
+ }
223
+ return;
224
+ }
225
+ handlers.onMessage(event.data);
226
+ };
227
+ source.onerror = () => {
228
+ if (this.closed) {
229
+ return;
230
+ }
231
+ this.closed = true;
232
+ this.source?.close();
233
+ this.source = null;
234
+ handlers.onClose();
235
+ };
236
+ }
237
+ send(raw) {
238
+ if (!this.cid) {
239
+ this.queue.push(raw);
240
+ return;
241
+ }
242
+ const post = this.options.fetch ?? fetch;
243
+ void post(`${this.baseUrl}${this.options.controlPath ?? '/live/control'}`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({ cid: this.cid, message: JSON.parse(raw) })
247
+ }).catch(() => {
248
+ // A failed control POST is not a dead stream. The stream's own
249
+ // error handler owns the disconnect.
250
+ });
251
+ }
252
+ close() {
253
+ this.closed = true;
254
+ this.source?.close();
255
+ this.source = null;
256
+ this.cid = null;
257
+ }
258
+ }
259
+ exports.SseClientTransport = SseClientTransport;
260
+ /**
261
+ * The floor: conditional GET on the route the resource already serves.
262
+ *
263
+ * There are no patches here, only snapshots and 304s. A patch would need the
264
+ * server to remember this client's previous revision, which is precisely the
265
+ * history §8.1 says does not exist.
266
+ */
267
+ class PollingTransport {
268
+ constructor(baseUrl, routes, options = {}) {
269
+ this.baseUrl = baseUrl;
270
+ this.routes = routes;
271
+ this.options = options;
272
+ this.kind = 'polling';
273
+ this.handlers = null;
274
+ this.polls = new Map();
275
+ this.connectionId = pollingConnectionId();
276
+ this.timer = null;
277
+ this.token = options.token;
278
+ }
279
+ start(handlers) {
280
+ this.handlers = handlers;
281
+ handlers.onOpen();
282
+ const interval = this.options.intervalMs ?? 5000;
283
+ this.timer = setInterval(() => this.tick(), interval);
284
+ this.timer.unref?.();
285
+ }
286
+ send(raw) {
287
+ let message;
288
+ try {
289
+ message = JSON.parse(raw);
290
+ }
291
+ catch {
292
+ return;
293
+ }
294
+ if (message.t === 'unsub') {
295
+ this.polls.delete(message.sid);
296
+ return;
297
+ }
298
+ if (message.t === 'hello') {
299
+ this.token = message.token;
300
+ return;
301
+ }
302
+ if (message.t !== 'sub') {
303
+ // `resync` is answered by the next tick anyway.
304
+ return;
305
+ }
306
+ const route = this.routes[message.resource];
307
+ if (!route) {
308
+ this.emit({
309
+ t: 'error',
310
+ sid: message.sid,
311
+ code: 'no_route',
312
+ message: `Polling cannot reach "${message.resource}": pass the generated \`routes\` ` +
313
+ `to the LiveClient so it can turn a resource id into a URL.`
314
+ });
315
+ return;
316
+ }
317
+ const poll = {
318
+ sid: message.sid,
319
+ resourceId: message.resource,
320
+ url: buildUrl(this.baseUrl, route.path, message.inputs),
321
+ etag: message.hash ? `"${message.hash}"` : null,
322
+ revision: 0
323
+ };
324
+ this.polls.set(message.sid, poll);
325
+ void this.fetchOne(poll);
326
+ }
327
+ close() {
328
+ if (this.timer) {
329
+ clearInterval(this.timer);
330
+ this.timer = null;
331
+ }
332
+ this.polls.clear();
333
+ }
334
+ tick() {
335
+ for (const poll of this.polls.values()) {
336
+ void this.fetchOne(poll);
337
+ }
338
+ }
339
+ async fetchOne(poll) {
340
+ const get = this.options.fetch ?? fetch;
341
+ const headers = {};
342
+ headers[protocol_1.LIVE_POLL_HEADER] = '1';
343
+ headers[protocol_1.LIVE_CONNECTION_HEADER] = this.connectionId;
344
+ headers[protocol_1.LIVE_RESOURCE_HEADER] = poll.resourceId;
345
+ if (this.token) {
346
+ headers[protocol_1.LIVE_TOKEN_HEADER] = this.token;
347
+ }
348
+ if (poll.etag) {
349
+ headers['If-None-Match'] = poll.etag;
350
+ }
351
+ let response;
352
+ try {
353
+ response = await get(poll.url, { headers });
354
+ }
355
+ catch (error) {
356
+ this.emit({ t: 'stale', sid: poll.sid, reason: error.message });
357
+ return;
358
+ }
359
+ if (response.status === 304) {
360
+ // Already right on screen. Emitting a snapshot would hand the
361
+ // store a new object for identical content and re-render for it.
362
+ return;
363
+ }
364
+ if (!response.ok) {
365
+ if (response.status === 401 || response.status === 403) {
366
+ this.polls.delete(poll.sid);
367
+ this.emit({
368
+ t: 'error',
369
+ sid: poll.sid,
370
+ code: 'forbidden',
371
+ message: 'Polling authorization failed.'
372
+ });
373
+ return;
374
+ }
375
+ if (response.status >= 400 && response.status < 500) {
376
+ this.polls.delete(poll.sid);
377
+ this.emit({
378
+ t: 'error',
379
+ sid: poll.sid,
380
+ code: 'invalid_subscription',
381
+ message: `Polling route returned HTTP ${response.status}.`
382
+ });
383
+ return;
384
+ }
385
+ this.emit({ t: 'stale', sid: poll.sid, reason: `HTTP ${response.status}` });
386
+ return;
387
+ }
388
+ const tag = response.headers.get('ETag');
389
+ poll.etag = tag;
390
+ poll.revision += 1;
391
+ this.emit({
392
+ t: 'snapshot',
393
+ sid: poll.sid,
394
+ rev: poll.revision,
395
+ hash: tag ? tag.replace(/"/g, '') : '',
396
+ data: await response.json()
397
+ });
398
+ }
399
+ emit(message) {
400
+ this.handlers?.onMessage(JSON.stringify(message));
401
+ }
402
+ }
403
+ exports.PollingTransport = PollingTransport;
404
+ function pollingConnectionId() {
405
+ const uuid = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
406
+ ? crypto.randomUUID()
407
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
408
+ return `poll:${uuid}`;
409
+ }
410
+ function buildUrl(baseUrl, path, inputs) {
411
+ const filled = path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name) => {
412
+ const value = inputs.params?.[name];
413
+ if (value === undefined) {
414
+ throw new Error(`Missing path parameter "${name}" for ${path}.`);
415
+ }
416
+ return encodeURIComponent(String(value));
417
+ });
418
+ const search = new URLSearchParams();
419
+ for (const [key, value] of Object.entries(inputs.query ?? {})) {
420
+ if (Array.isArray(value)) {
421
+ for (const item of value) {
422
+ search.append(key, String(item));
423
+ }
424
+ }
425
+ else if (value !== undefined && value !== null) {
426
+ search.set(key, String(value));
427
+ }
428
+ }
429
+ const suffix = search.toString();
430
+ return `${baseUrl}${filled}${suffix ? `?${suffix}` : ''}`;
431
+ }
@@ -0,0 +1,54 @@
1
+ import { type LiveDataOf, type LiveDescriptor, type LiveInputsOf } from '../shared/descriptor';
2
+ import type { LiveInputs } from '../shared/inputs';
3
+ import type { LiveClient, LiveState, LiveStore } from './core';
4
+ /** A subscription held by something that is not a component. */
5
+ export interface LiveHandle<T> {
6
+ /** Current state. Valid before, during and after a subscription. */
7
+ get(): LiveState<T>;
8
+ /**
9
+ * Start receiving. The listener fires on every change, never on
10
+ * registration -- read `get()` for the initial value.
11
+ *
12
+ * Returns the same function `close()` calls.
13
+ */
14
+ subscribe(listener: (state: LiveState<T>) => void): () => void;
15
+ /** Release this handle's hold. Other handles on the same data keep theirs. */
16
+ close(): void;
17
+ }
18
+ /**
19
+ * Resolve a descriptor or a resource id to the underlying store.
20
+ *
21
+ * Framework adapters go through here rather than through `liveStore()`: they
22
+ * already own their own teardown, and wrapping a second lifecycle around the
23
+ * one the framework gives them is how an adapter starts leaking.
24
+ */
25
+ export declare function liveStoreOf(client: LiveClient, resource: string | LiveDescriptor<any>, inputs?: Record<string, any>): LiveStore<unknown>;
26
+ /**
27
+ * Canonical identity of a subscription, for adapters that need to know whether
28
+ * reactive inputs actually changed before tearing a subscription down.
29
+ */
30
+ export declare function liveIdentity(resource: string | LiveDescriptor<any>, inputs?: Record<string, any>): string;
31
+ export declare function liveStore<R>(client: LiveClient, descriptor: LiveDescriptor<R>, inputs?: LiveInputsOf<R>): LiveHandle<LiveDataOf<R>>;
32
+ export declare function liveStore<T>(client: LiveClient, resourceId: string, inputs?: Partial<LiveInputs>): LiveHandle<T>;
33
+ /**
34
+ * A subscription that points at one target at a time.
35
+ *
36
+ * Reactive frameworks re-run an expression when its inputs change, and for a
37
+ * live subscription that means "this component now wants a different instance".
38
+ * The rule that makes that safe has nothing to do with any framework, so it
39
+ * lives here: recomputing to the same inputs must not churn the subscription,
40
+ * and switching targets must release the old one *before* retaining the new
41
+ * one. Holding both across the switch is how a dragged filter walks a
42
+ * connection into `maxInstancesPerConnection`.
43
+ */
44
+ export declare class LiveSlot<T> {
45
+ private readonly client;
46
+ private readonly onState;
47
+ private identity;
48
+ private release;
49
+ private store;
50
+ constructor(client: LiveClient, onState: (state: LiveState<T>) => void);
51
+ point(resource: string | LiveDescriptor<any>, inputs?: Record<string, any>): void;
52
+ get(): LiveState<T>;
53
+ close(): void;
54
+ }