@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,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
|
+
}
|