@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,111 @@
|
|
|
1
|
+
import type { LiveInputs } from '../shared/inputs';
|
|
2
|
+
import { type ClientTransport } from './transport';
|
|
3
|
+
export interface LiveState<T> {
|
|
4
|
+
data: T | undefined;
|
|
5
|
+
pending: boolean;
|
|
6
|
+
error: string | null;
|
|
7
|
+
/** The server cannot vouch for this being current. Data still shown. */
|
|
8
|
+
stale: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface LiveStore<T> {
|
|
11
|
+
subscribe(listener: () => void): () => void;
|
|
12
|
+
getSnapshot(): LiveState<T>;
|
|
13
|
+
}
|
|
14
|
+
/** The slice of WebSocket this client uses, and the seam tests inject through. */
|
|
15
|
+
export interface LiveSocket {
|
|
16
|
+
send(data: string): void;
|
|
17
|
+
close(): void;
|
|
18
|
+
onopen: (() => void) | null;
|
|
19
|
+
onmessage: ((event: {
|
|
20
|
+
data: string;
|
|
21
|
+
}) => void) | null;
|
|
22
|
+
onclose: (() => void) | null;
|
|
23
|
+
onerror: ((error: unknown) => void) | null;
|
|
24
|
+
}
|
|
25
|
+
export interface LiveClientOptions {
|
|
26
|
+
url: string;
|
|
27
|
+
token?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Server-rendered payloads, keyed by `${resource}|${canonical(inputs)}`.
|
|
30
|
+
* Lets the first paint skip the waterfall: the store starts full and the
|
|
31
|
+
* subscription only says "this is the hash I already have".
|
|
32
|
+
*/
|
|
33
|
+
hydrate?: Record<string, {
|
|
34
|
+
data: unknown;
|
|
35
|
+
hash: string;
|
|
36
|
+
}>;
|
|
37
|
+
unsubGraceMs?: number;
|
|
38
|
+
reconnect?: {
|
|
39
|
+
initialMs?: number;
|
|
40
|
+
maxMs?: number;
|
|
41
|
+
};
|
|
42
|
+
socketFactory?: (url: string) => LiveSocket;
|
|
43
|
+
/**
|
|
44
|
+
* Builds the pipe. Defaults to the WebSocket → SSE → polling ladder.
|
|
45
|
+
* `socketFactory` still works and is the shorthand for "same ladder,
|
|
46
|
+
* different socket" that the tests use.
|
|
47
|
+
*/
|
|
48
|
+
transportFactory?: (url: string) => ClientTransport;
|
|
49
|
+
/**
|
|
50
|
+
* The `routes` object the @carno.js/client codegen emits. Only the polling
|
|
51
|
+
* rung needs it -- without it the ladder stops at SSE, and says so.
|
|
52
|
+
*/
|
|
53
|
+
routes?: unknown;
|
|
54
|
+
pollIntervalMs?: number;
|
|
55
|
+
transportProbeMs?: number;
|
|
56
|
+
/** Origin the SSE and polling rungs call. Defaults to the page's own. */
|
|
57
|
+
httpBaseUrl?: string;
|
|
58
|
+
/** SSE downstream path. Must match the server's `config.ssePath`. */
|
|
59
|
+
ssePath?: string;
|
|
60
|
+
/** SSE control path. Must match the server's `config.sseControlPath`. */
|
|
61
|
+
sseControlPath?: string;
|
|
62
|
+
}
|
|
63
|
+
export declare class LiveClient {
|
|
64
|
+
private readonly options;
|
|
65
|
+
private readonly entries;
|
|
66
|
+
private readonly bySid;
|
|
67
|
+
private readonly overlays;
|
|
68
|
+
private nextOverlay;
|
|
69
|
+
private pipe;
|
|
70
|
+
private connected;
|
|
71
|
+
private closed;
|
|
72
|
+
private attempt;
|
|
73
|
+
private reconnectTimer;
|
|
74
|
+
private nextSid;
|
|
75
|
+
constructor(options: LiveClientOptions);
|
|
76
|
+
store<T>(resource: string, inputs: LiveInputs): LiveStore<T>;
|
|
77
|
+
/**
|
|
78
|
+
* Show something the server has not confirmed yet.
|
|
79
|
+
*
|
|
80
|
+
* The overlay is a projection over the confirmed snapshot, never a write
|
|
81
|
+
* into it: a patch arriving while the action is in flight lands on the
|
|
82
|
+
* snapshot and the overlay is re-projected on top, so the screen never
|
|
83
|
+
* flickers back to a state the server does not know about.
|
|
84
|
+
*
|
|
85
|
+
* `apply` receives a mutable draft: mutate it, do not return a new value.
|
|
86
|
+
* Returns the function that removes the overlay.
|
|
87
|
+
*/
|
|
88
|
+
overlay(resourceId: string, apply: (draft: any) => void): () => void;
|
|
89
|
+
close(): void;
|
|
90
|
+
/** Which pipe is carrying this client right now. For logs, not for logic. */
|
|
91
|
+
transport(): ClientTransport['kind'] | null;
|
|
92
|
+
private retain;
|
|
93
|
+
private ensureConnected;
|
|
94
|
+
/**
|
|
95
|
+
* WebSocket, then SSE, then polling. Rungs whose prerequisites are missing
|
|
96
|
+
* are not offered: a polling rung with no route index would fail every
|
|
97
|
+
* subscription with the same error.
|
|
98
|
+
*/
|
|
99
|
+
private buildLadder;
|
|
100
|
+
private onDisconnect;
|
|
101
|
+
private sendSub;
|
|
102
|
+
private send;
|
|
103
|
+
private onMessage;
|
|
104
|
+
/** A server error ends this subscription; keep its store state, not its binding. */
|
|
105
|
+
private terminate;
|
|
106
|
+
/** Confirmed snapshot plus every overlay registered for this resource. */
|
|
107
|
+
private project;
|
|
108
|
+
private reproject;
|
|
109
|
+
private update;
|
|
110
|
+
}
|
|
111
|
+
export declare function storeKey(resource: string, inputs: LiveInputs): string;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LiveClient = void 0;
|
|
4
|
+
exports.storeKey = storeKey;
|
|
5
|
+
const PatchEngine_1 = require("../patch/PatchEngine");
|
|
6
|
+
const canonical_1 = require("../shared/canonical");
|
|
7
|
+
const protocol_1 = require("../shared/protocol");
|
|
8
|
+
const transport_1 = require("./transport");
|
|
9
|
+
const DEFAULT_UNSUB_GRACE_MS = 5000;
|
|
10
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 250;
|
|
11
|
+
const DEFAULT_MAX_BACKOFF_MS = 30000;
|
|
12
|
+
class LiveClient {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
this.entries = new Map();
|
|
16
|
+
this.bySid = new Map();
|
|
17
|
+
this.overlays = new Map();
|
|
18
|
+
this.nextOverlay = 0;
|
|
19
|
+
this.pipe = null;
|
|
20
|
+
this.connected = false;
|
|
21
|
+
this.closed = false;
|
|
22
|
+
this.attempt = 0;
|
|
23
|
+
this.reconnectTimer = null;
|
|
24
|
+
this.nextSid = 0;
|
|
25
|
+
}
|
|
26
|
+
store(resource, inputs) {
|
|
27
|
+
const key = storeKey(resource, inputs);
|
|
28
|
+
const existing = this.entries.get(key);
|
|
29
|
+
if (existing) {
|
|
30
|
+
return existing.store;
|
|
31
|
+
}
|
|
32
|
+
const hydrated = this.options.hydrate?.[key];
|
|
33
|
+
const entry = {
|
|
34
|
+
sid: `s${this.nextSid++}`,
|
|
35
|
+
key,
|
|
36
|
+
resource,
|
|
37
|
+
inputs,
|
|
38
|
+
refs: 0,
|
|
39
|
+
revision: hydrated ? 1 : 0,
|
|
40
|
+
hash: hydrated?.hash ?? null,
|
|
41
|
+
patcher: new PatchEngine_1.PatchEngine(),
|
|
42
|
+
confirmed: hydrated?.data,
|
|
43
|
+
state: {
|
|
44
|
+
data: hydrated?.data,
|
|
45
|
+
pending: hydrated === undefined,
|
|
46
|
+
error: null,
|
|
47
|
+
stale: false
|
|
48
|
+
},
|
|
49
|
+
listeners: new Set(),
|
|
50
|
+
dropTimer: null,
|
|
51
|
+
terminal: false,
|
|
52
|
+
store: undefined
|
|
53
|
+
};
|
|
54
|
+
entry.store = {
|
|
55
|
+
subscribe: (listener) => this.retain(entry, listener),
|
|
56
|
+
getSnapshot: () => entry.state
|
|
57
|
+
};
|
|
58
|
+
this.entries.set(key, entry);
|
|
59
|
+
this.bySid.set(entry.sid, entry);
|
|
60
|
+
return entry.store;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Show something the server has not confirmed yet.
|
|
64
|
+
*
|
|
65
|
+
* The overlay is a projection over the confirmed snapshot, never a write
|
|
66
|
+
* into it: a patch arriving while the action is in flight lands on the
|
|
67
|
+
* snapshot and the overlay is re-projected on top, so the screen never
|
|
68
|
+
* flickers back to a state the server does not know about.
|
|
69
|
+
*
|
|
70
|
+
* `apply` receives a mutable draft: mutate it, do not return a new value.
|
|
71
|
+
* Returns the function that removes the overlay.
|
|
72
|
+
*/
|
|
73
|
+
overlay(resourceId, apply) {
|
|
74
|
+
const id = this.nextOverlay++;
|
|
75
|
+
this.overlays.set(id, { resource: resourceId, apply: apply });
|
|
76
|
+
this.reproject(resourceId);
|
|
77
|
+
return () => {
|
|
78
|
+
if (this.overlays.delete(id)) {
|
|
79
|
+
this.reproject(resourceId);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
close() {
|
|
84
|
+
this.closed = true;
|
|
85
|
+
if (this.reconnectTimer) {
|
|
86
|
+
clearTimeout(this.reconnectTimer);
|
|
87
|
+
this.reconnectTimer = null;
|
|
88
|
+
}
|
|
89
|
+
this.pipe?.close();
|
|
90
|
+
this.pipe = null;
|
|
91
|
+
this.connected = false;
|
|
92
|
+
}
|
|
93
|
+
/** Which pipe is carrying this client right now. For logs, not for logic. */
|
|
94
|
+
transport() {
|
|
95
|
+
return this.connected ? (this.pipe?.kind ?? null) : null;
|
|
96
|
+
}
|
|
97
|
+
// ------------------------------------------------------------ lifecycle
|
|
98
|
+
retain(entry, listener) {
|
|
99
|
+
entry.listeners.add(listener);
|
|
100
|
+
entry.refs += 1;
|
|
101
|
+
if (entry.dropTimer) {
|
|
102
|
+
clearTimeout(entry.dropTimer);
|
|
103
|
+
entry.dropTimer = null;
|
|
104
|
+
}
|
|
105
|
+
if (entry.refs === 1) {
|
|
106
|
+
const openedSynchronously = this.ensureConnected();
|
|
107
|
+
if (!openedSynchronously) {
|
|
108
|
+
this.sendSub(entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return () => {
|
|
112
|
+
entry.listeners.delete(listener);
|
|
113
|
+
entry.refs -= 1;
|
|
114
|
+
if (entry.terminal || entry.refs > 0 || entry.dropTimer) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Grace period: coming back from a navigation must not tear the
|
|
118
|
+
// subscription down and build it again.
|
|
119
|
+
entry.dropTimer = setTimeout(() => {
|
|
120
|
+
entry.dropTimer = null;
|
|
121
|
+
if (entry.refs > 0) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.send({ t: 'unsub', sid: entry.sid });
|
|
125
|
+
this.entries.delete(entry.key);
|
|
126
|
+
this.bySid.delete(entry.sid);
|
|
127
|
+
}, this.options.unsubGraceMs ?? DEFAULT_UNSUB_GRACE_MS);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
ensureConnected() {
|
|
131
|
+
if (this.pipe || this.closed) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const build = this.options.transportFactory ?? ((url) => this.buildLadder(url));
|
|
135
|
+
const pipe = build(this.options.url);
|
|
136
|
+
this.pipe = pipe;
|
|
137
|
+
let openedSynchronously = false;
|
|
138
|
+
pipe.start({
|
|
139
|
+
onOpen: () => {
|
|
140
|
+
openedSynchronously = true;
|
|
141
|
+
this.connected = true;
|
|
142
|
+
this.attempt = 0;
|
|
143
|
+
this.send({ t: 'hello', v: protocol_1.LIVE_PROTOCOL_VERSION, token: this.options.token });
|
|
144
|
+
// Reconnect is just "subscribe again, carrying the hash of
|
|
145
|
+
// what is on screen". There is no session to restore, because
|
|
146
|
+
// there is no session.
|
|
147
|
+
for (const entry of this.entries.values()) {
|
|
148
|
+
if (entry.refs > 0) {
|
|
149
|
+
this.sendSub(entry);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
onMessage: raw => this.onMessage(raw),
|
|
154
|
+
onClose: () => this.onDisconnect()
|
|
155
|
+
});
|
|
156
|
+
return openedSynchronously;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* WebSocket, then SSE, then polling. Rungs whose prerequisites are missing
|
|
160
|
+
* are not offered: a polling rung with no route index would fail every
|
|
161
|
+
* subscription with the same error.
|
|
162
|
+
*/
|
|
163
|
+
buildLadder(url) {
|
|
164
|
+
const origin = this.options.httpBaseUrl
|
|
165
|
+
?? url.replace(/^ws/, 'http').replace(/\/live\/?$/, '');
|
|
166
|
+
const index = (0, transport_1.routeIndex)(this.options.routes);
|
|
167
|
+
const rungs = [
|
|
168
|
+
() => new transport_1.WebSocketTransport(url, this.options.socketFactory),
|
|
169
|
+
() => new transport_1.SseClientTransport(origin, {
|
|
170
|
+
streamPath: this.options.ssePath,
|
|
171
|
+
controlPath: this.options.sseControlPath
|
|
172
|
+
})
|
|
173
|
+
];
|
|
174
|
+
if (Object.keys(index).length > 0) {
|
|
175
|
+
rungs.push(() => new transport_1.PollingTransport(origin, index, {
|
|
176
|
+
intervalMs: this.options.pollIntervalMs,
|
|
177
|
+
token: this.options.token
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
return new transport_1.LadderTransport(rungs, { probeMs: this.options.transportProbeMs ?? 3000 });
|
|
181
|
+
}
|
|
182
|
+
onDisconnect() {
|
|
183
|
+
this.connected = false;
|
|
184
|
+
this.pipe = null;
|
|
185
|
+
if (this.closed || this.reconnectTimer) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const initial = this.options.reconnect?.initialMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
|
189
|
+
const max = this.options.reconnect?.maxMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
190
|
+
const ceiling = Math.min(max, initial * 2 ** this.attempt);
|
|
191
|
+
// Full jitter, and it is mandatory: a deploy reconnects every client at
|
|
192
|
+
// once, and a synchronised recompute storm takes the database down.
|
|
193
|
+
const delay = Math.random() * ceiling;
|
|
194
|
+
this.attempt += 1;
|
|
195
|
+
this.reconnectTimer = setTimeout(() => {
|
|
196
|
+
this.reconnectTimer = null;
|
|
197
|
+
this.ensureConnected();
|
|
198
|
+
}, delay);
|
|
199
|
+
}
|
|
200
|
+
sendSub(entry) {
|
|
201
|
+
this.send({
|
|
202
|
+
t: 'sub',
|
|
203
|
+
sid: entry.sid,
|
|
204
|
+
resource: entry.resource,
|
|
205
|
+
inputs: entry.inputs,
|
|
206
|
+
hash: entry.hash ?? undefined
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
send(message) {
|
|
210
|
+
if (!this.pipe || !this.connected) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.pipe.send(JSON.stringify(message));
|
|
214
|
+
}
|
|
215
|
+
// -------------------------------------------------------------- inbound
|
|
216
|
+
onMessage(raw) {
|
|
217
|
+
let message;
|
|
218
|
+
try {
|
|
219
|
+
message = JSON.parse(raw);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const entry = this.bySid.get(message.sid ?? '');
|
|
225
|
+
if (!entry) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
switch (message.t) {
|
|
229
|
+
case 'snapshot':
|
|
230
|
+
if (message.key) {
|
|
231
|
+
entry.patcher = new PatchEngine_1.PatchEngine(message.key);
|
|
232
|
+
}
|
|
233
|
+
entry.revision = message.rev;
|
|
234
|
+
entry.hash = message.hash;
|
|
235
|
+
entry.confirmed = message.data;
|
|
236
|
+
this.project(entry, { pending: false, error: null, stale: false });
|
|
237
|
+
return;
|
|
238
|
+
case 'current':
|
|
239
|
+
if (message.key) {
|
|
240
|
+
entry.patcher = new PatchEngine_1.PatchEngine(message.key);
|
|
241
|
+
}
|
|
242
|
+
entry.revision = message.rev;
|
|
243
|
+
entry.hash = message.hash;
|
|
244
|
+
// Content already on screen: touch only the flags, keep the
|
|
245
|
+
// data referentially identical so nothing re-renders.
|
|
246
|
+
this.project(entry, { pending: false, error: null, stale: false });
|
|
247
|
+
return;
|
|
248
|
+
case 'patch':
|
|
249
|
+
if (message.from !== entry.revision) {
|
|
250
|
+
// A hole in the sequence. Ask for full state rather than
|
|
251
|
+
// applying ops to a base we cannot vouch for.
|
|
252
|
+
this.send({ t: 'resync', sid: entry.sid, hash: entry.hash ?? undefined });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
entry.revision = message.to;
|
|
256
|
+
entry.hash = message.hash;
|
|
257
|
+
entry.confirmed = entry.patcher.apply(entry.confirmed, message.ops);
|
|
258
|
+
this.project(entry, { pending: false, error: null, stale: false });
|
|
259
|
+
return;
|
|
260
|
+
case 'stale':
|
|
261
|
+
this.update(entry, { ...entry.state, stale: true });
|
|
262
|
+
return;
|
|
263
|
+
case 'error':
|
|
264
|
+
this.terminate(entry);
|
|
265
|
+
this.update(entry, { ...entry.state, pending: false, error: message.message });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** A server error ends this subscription; keep its store state, not its binding. */
|
|
270
|
+
terminate(entry) {
|
|
271
|
+
entry.terminal = true;
|
|
272
|
+
if (entry.dropTimer) {
|
|
273
|
+
clearTimeout(entry.dropTimer);
|
|
274
|
+
entry.dropTimer = null;
|
|
275
|
+
}
|
|
276
|
+
if (this.entries.get(entry.key) === entry) {
|
|
277
|
+
this.entries.delete(entry.key);
|
|
278
|
+
}
|
|
279
|
+
if (this.bySid.get(entry.sid) === entry) {
|
|
280
|
+
this.bySid.delete(entry.sid);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Confirmed snapshot plus every overlay registered for this resource. */
|
|
284
|
+
project(entry, flags) {
|
|
285
|
+
const overlays = [...this.overlays.values()].filter(overlay => overlay.resource === entry.resource);
|
|
286
|
+
if (overlays.length === 0 || entry.confirmed === undefined) {
|
|
287
|
+
this.update(entry, { ...flags, data: entry.confirmed });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const draft = structuredClone(entry.confirmed);
|
|
291
|
+
for (const overlay of overlays) {
|
|
292
|
+
try {
|
|
293
|
+
overlay.apply(draft);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
// A broken optimistic update must not take the real data with
|
|
297
|
+
// it: the confirmed snapshot is still correct underneath.
|
|
298
|
+
console.error('[carno:live] an optimistic overlay failed', error);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
this.update(entry, { ...flags, data: draft });
|
|
302
|
+
}
|
|
303
|
+
reproject(resourceId) {
|
|
304
|
+
for (const entry of this.entries.values()) {
|
|
305
|
+
if (entry.resource !== resourceId) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
this.project(entry, {
|
|
309
|
+
pending: entry.state.pending,
|
|
310
|
+
error: entry.state.error,
|
|
311
|
+
stale: entry.state.stale
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
update(entry, next) {
|
|
316
|
+
if (next.data === entry.state.data &&
|
|
317
|
+
next.pending === entry.state.pending &&
|
|
318
|
+
next.error === entry.state.error &&
|
|
319
|
+
next.stale === entry.state.stale) {
|
|
320
|
+
// Nothing changed. Keeping the same object is what makes
|
|
321
|
+
// useSyncExternalStore stable instead of looping.
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
entry.state = next;
|
|
325
|
+
for (const listener of entry.listeners) {
|
|
326
|
+
listener();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
exports.LiveClient = LiveClient;
|
|
331
|
+
function storeKey(resource, inputs) {
|
|
332
|
+
return `${resource}|${(0, canonical_1.canonical)({
|
|
333
|
+
params: inputs.params ?? {},
|
|
334
|
+
query: inputs.query ?? {},
|
|
335
|
+
body: inputs.body ?? null
|
|
336
|
+
})}`;
|
|
337
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LivePayload } from '../resource/prefetch';
|
|
2
|
+
/** Attribute the island helper marks its payload scripts with. */
|
|
3
|
+
export declare const HYDRATION_ATTRIBUTE = "data-carno-live";
|
|
4
|
+
/** Exactly the key `LiveClient.store()` will look up. */
|
|
5
|
+
export declare function hydrationKey(payload: LivePayload): string;
|
|
6
|
+
export declare function toHydrateMap(payloads: LivePayload[]): Record<string, {
|
|
7
|
+
data: unknown;
|
|
8
|
+
hash: string;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Collect every island payload the server embedded in the page.
|
|
12
|
+
*
|
|
13
|
+
* A malformed one is skipped rather than thrown: one broken island must not
|
|
14
|
+
* cost the page every other island's first paint.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readHydrationPayload(root?: ParentNode): Record<string, {
|
|
17
|
+
data: unknown;
|
|
18
|
+
hash: string;
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HYDRATION_ATTRIBUTE = void 0;
|
|
4
|
+
exports.hydrationKey = hydrationKey;
|
|
5
|
+
exports.toHydrateMap = toHydrateMap;
|
|
6
|
+
exports.readHydrationPayload = readHydrationPayload;
|
|
7
|
+
const core_1 = require("./core");
|
|
8
|
+
/** Attribute the island helper marks its payload scripts with. */
|
|
9
|
+
exports.HYDRATION_ATTRIBUTE = 'data-carno-live';
|
|
10
|
+
/** Exactly the key `LiveClient.store()` will look up. */
|
|
11
|
+
function hydrationKey(payload) {
|
|
12
|
+
return (0, core_1.storeKey)(payload.resourceId, payload.inputs);
|
|
13
|
+
}
|
|
14
|
+
function toHydrateMap(payloads) {
|
|
15
|
+
const map = {};
|
|
16
|
+
for (const payload of payloads) {
|
|
17
|
+
if (!isLivePayload(payload)) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
map[hydrationKey(payload)] = { data: payload.data, hash: payload.hash };
|
|
21
|
+
}
|
|
22
|
+
return map;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Collect every island payload the server embedded in the page.
|
|
26
|
+
*
|
|
27
|
+
* A malformed one is skipped rather than thrown: one broken island must not
|
|
28
|
+
* cost the page every other island's first paint.
|
|
29
|
+
*/
|
|
30
|
+
function readHydrationPayload(root = document) {
|
|
31
|
+
const payloads = [];
|
|
32
|
+
for (const node of root.querySelectorAll(`script[${exports.HYDRATION_ATTRIBUTE}]`)) {
|
|
33
|
+
try {
|
|
34
|
+
payloads.push(JSON.parse(node.textContent ?? ''));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Skip it. The client will fetch that one instead.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return toHydrateMap(payloads);
|
|
41
|
+
}
|
|
42
|
+
function isLivePayload(value) {
|
|
43
|
+
if (!isRecord(value) || typeof value.resourceId !== 'string' || value.resourceId.length === 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return typeof value.hash === 'string'
|
|
47
|
+
&& value.hash.length > 0
|
|
48
|
+
&& isLiveInputs(value.inputs);
|
|
49
|
+
}
|
|
50
|
+
function isLiveInputs(value) {
|
|
51
|
+
if (!isRecord(value)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const params = value.params;
|
|
55
|
+
const query = value.query;
|
|
56
|
+
if (!isRecord(params) || !isRecord(query)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return Object.values(params).every(item => typeof item === 'string')
|
|
60
|
+
&& Object.values(query).every(item => typeof item === 'string'
|
|
61
|
+
|| (Array.isArray(item) && item.every(value => typeof value === 'string')));
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LiveDataOf, LiveDescriptor } from '../shared/descriptor';
|
|
2
|
+
/**
|
|
3
|
+
* One optimistic projection: which resource it targets, and how the action's
|
|
4
|
+
* payload changes it.
|
|
5
|
+
*
|
|
6
|
+
* `on` is what makes `draft` typed. Without naming the target, the draft would
|
|
7
|
+
* have to be `any`, and an optimistic update on `any` is a guess the compiler
|
|
8
|
+
* cannot check.
|
|
9
|
+
*/
|
|
10
|
+
export interface OptimisticEntry<Target, Dto> {
|
|
11
|
+
on: Target;
|
|
12
|
+
apply: (draft: LiveDataOf<Target extends LiveDescriptor<infer R> ? R : never>, dto: Dto) => void;
|
|
13
|
+
}
|
|
14
|
+
/** Maps a tuple of descriptors to the matching tuple of optimistic entries. */
|
|
15
|
+
export type OptimisticList<Targets extends readonly unknown[], Dto> = {
|
|
16
|
+
[K in keyof Targets]: OptimisticEntry<Targets[K], Dto>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { type LiveDataOf, type LiveDescriptor, type LiveInputsOf } from '../shared/descriptor';
|
|
3
|
+
import type { LiveInputs } from '../shared/inputs';
|
|
4
|
+
import type { LiveClient, LiveState } from './core';
|
|
5
|
+
import type { OptimisticList } from './optimistic';
|
|
6
|
+
export declare const LiveContext: import("react").Context<LiveClient>;
|
|
7
|
+
export declare function LiveProvider(props: {
|
|
8
|
+
client: LiveClient;
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
}): ReactElement;
|
|
11
|
+
export declare function useLive<T>(resource: string, inputs?: Partial<LiveInputs>): LiveState<T>;
|
|
12
|
+
export declare function useLive<R>(descriptor: LiveDescriptor<R>, inputs?: LiveInputsOf<R>): LiveState<LiveDataOf<R>>;
|
|
13
|
+
/**
|
|
14
|
+
* Run an action, showing its expected effect immediately.
|
|
15
|
+
*
|
|
16
|
+
* The overlay lives above the confirmed snapshot, so a server patch arriving
|
|
17
|
+
* mid-flight lands underneath it and nothing flickers. It is removed when the
|
|
18
|
+
* action settles, either way.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useLiveAction<Dto, Result, const Targets extends readonly LiveDescriptor<any>[]>(action: (dto: Dto) => Promise<Result>, options?: {
|
|
21
|
+
optimistic?: OptimisticList<Targets, Dto>;
|
|
22
|
+
}): (dto: Dto) => Promise<Result>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LiveContext = void 0;
|
|
4
|
+
exports.LiveProvider = LiveProvider;
|
|
5
|
+
exports.useLive = useLive;
|
|
6
|
+
exports.useLiveAction = useLiveAction;
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const canonical_1 = require("../shared/canonical");
|
|
9
|
+
const descriptor_1 = require("../shared/descriptor");
|
|
10
|
+
exports.LiveContext = (0, react_1.createContext)(null);
|
|
11
|
+
function LiveProvider(props) {
|
|
12
|
+
return (0, react_1.createElement)(exports.LiveContext.Provider, { value: props.client }, props.children);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Subscribe a component to server-owned state.
|
|
16
|
+
*
|
|
17
|
+
* The component keeps its own local state next to this — selected row, open
|
|
18
|
+
* modal, focused input. None of that travels; only the server's data does.
|
|
19
|
+
*/
|
|
20
|
+
function useLive(resource,
|
|
21
|
+
// Loose on purpose, and invisible to callers: a descriptor's own `query`
|
|
22
|
+
// type is a plain object, which no index signature accepts. The overloads
|
|
23
|
+
// above are what anyone actually sees.
|
|
24
|
+
inputs = {}) {
|
|
25
|
+
const client = (0, react_1.useContext)(exports.LiveContext);
|
|
26
|
+
if (!client) {
|
|
27
|
+
throw new Error('useLive() requires a <LiveProvider client={...}> above it in the tree.');
|
|
28
|
+
}
|
|
29
|
+
const resourceId = typeof resource === 'string' ? resource : (0, descriptor_1.resourceIdOf)(resource);
|
|
30
|
+
const normalized = (0, descriptor_1.normalizeLiveInputs)(inputs);
|
|
31
|
+
const identity = (0, canonical_1.canonical)({
|
|
32
|
+
params: normalized.params,
|
|
33
|
+
query: normalized.query,
|
|
34
|
+
body: normalized.body ?? null
|
|
35
|
+
});
|
|
36
|
+
// Depend on the canonical form, not on the object: a new literal every
|
|
37
|
+
// render would resubscribe on every render.
|
|
38
|
+
const stable = (0, react_1.useMemo)(() => normalized, [identity]);
|
|
39
|
+
const store = (0, react_1.useMemo)(() => client.store(resourceId, stable), [client, resourceId, stable]);
|
|
40
|
+
return (0, react_1.useSyncExternalStore)(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run an action, showing its expected effect immediately.
|
|
44
|
+
*
|
|
45
|
+
* The overlay lives above the confirmed snapshot, so a server patch arriving
|
|
46
|
+
* mid-flight lands underneath it and nothing flickers. It is removed when the
|
|
47
|
+
* action settles, either way.
|
|
48
|
+
*/
|
|
49
|
+
function useLiveAction(action, options = {}) {
|
|
50
|
+
const client = (0, react_1.useContext)(exports.LiveContext);
|
|
51
|
+
if (!client) {
|
|
52
|
+
throw new Error('useLiveAction() requires a <LiveProvider client={...}> above it in the tree.');
|
|
53
|
+
}
|
|
54
|
+
// The array is a fresh literal on every render; a ref keeps the returned
|
|
55
|
+
// function stable without making the dependency list lie.
|
|
56
|
+
const specs = (0, react_1.useRef)([]);
|
|
57
|
+
specs.current = (options.optimistic ?? []);
|
|
58
|
+
return (0, react_1.useCallback)(async (dto) => {
|
|
59
|
+
const remove = specs.current.map(spec => client.overlay((0, descriptor_1.resourceIdOf)(spec.on), draft => spec.apply(draft, dto)));
|
|
60
|
+
try {
|
|
61
|
+
return await action(dto);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
for (const drop of remove) {
|
|
65
|
+
drop();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, [client, action]);
|
|
69
|
+
}
|