@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,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DestroyRef,
|
|
3
|
+
InjectionToken,
|
|
4
|
+
Injector,
|
|
5
|
+
computed,
|
|
6
|
+
effect,
|
|
7
|
+
inject,
|
|
8
|
+
signal,
|
|
9
|
+
type Provider,
|
|
10
|
+
type Signal
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import type { LiveDataOf, LiveDescriptor, LiveInputsOf } from '../shared/descriptor';
|
|
13
|
+
import type { LiveClient, LiveState } from './core';
|
|
14
|
+
import { LiveSlot } from './vanilla';
|
|
15
|
+
|
|
16
|
+
/** How a component finds the client without every call site passing it. */
|
|
17
|
+
export const LIVE_CLIENT = new InjectionToken<LiveClient>('carno.live.client');
|
|
18
|
+
|
|
19
|
+
export function provideLive(client: LiveClient): Provider {
|
|
20
|
+
return { provide: LIVE_CLIENT, useValue: client };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LiveSignalOptions {
|
|
24
|
+
/** Overrides the injected client. Mostly for tests and for multi-backend apps. */
|
|
25
|
+
client?: LiveClient;
|
|
26
|
+
/** Required when calling outside an injection context. */
|
|
27
|
+
injector?: Injector;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PENDING: LiveState<any> = { data: undefined, pending: true, error: null, stale: false };
|
|
31
|
+
|
|
32
|
+
/** bun:test Injector.create has no APP_EFFECT_SCHEDULER (an unnamed InjectionToken). */
|
|
33
|
+
function isMissingEffectScheduler(error: unknown): boolean {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
|
|
36
|
+
return message.includes('NullInjectorError') && message.includes('No provider for InjectionToken');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const reconcilers = new WeakMap<object, () => void>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The function production `effect()` calls. bun:test has no EffectScheduler,
|
|
43
|
+
* so tests invoke this in place of `TestBed.flushEffects()`.
|
|
44
|
+
*/
|
|
45
|
+
export function reconcileLiveSignal(state: object): void {
|
|
46
|
+
const reconcile = reconcilers.get(state);
|
|
47
|
+
|
|
48
|
+
if (!reconcile) {
|
|
49
|
+
throw new Error('reconcileLiveSignal() expected a liveSignal() state');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
reconcile();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function liveSignal<R>(
|
|
56
|
+
descriptor: LiveDescriptor<R>,
|
|
57
|
+
inputs?: () => LiveInputsOf<R>,
|
|
58
|
+
options?: LiveSignalOptions
|
|
59
|
+
): Signal<LiveState<LiveDataOf<R>>>;
|
|
60
|
+
export function liveSignal<T>(
|
|
61
|
+
resourceId: string,
|
|
62
|
+
inputs?: () => Record<string, any>,
|
|
63
|
+
options?: LiveSignalOptions
|
|
64
|
+
): Signal<LiveState<T>>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Subscribe a component to server-owned state, as a signal.
|
|
68
|
+
*
|
|
69
|
+
* `inputs` is read reactively, so changing a signal it touches re-points the
|
|
70
|
+
* subscription and cancels the previous one. Teardown is `DestroyRef`, so
|
|
71
|
+
* there is nothing to unsubscribe by hand, and nothing here touches zone.js.
|
|
72
|
+
*/
|
|
73
|
+
export function liveSignal(
|
|
74
|
+
resource: string | LiveDescriptor<any>,
|
|
75
|
+
inputs: () => Record<string, any> = () => ({}),
|
|
76
|
+
options: LiveSignalOptions = {}
|
|
77
|
+
): Signal<LiveState<any>> {
|
|
78
|
+
const client = options.client ?? inject(LIVE_CLIENT);
|
|
79
|
+
const destroyRef = options.injector
|
|
80
|
+
? options.injector.get(DestroyRef)
|
|
81
|
+
: inject(DestroyRef);
|
|
82
|
+
|
|
83
|
+
const state = signal<LiveState<any>>(PENDING);
|
|
84
|
+
const slot = new LiveSlot<any>(client, next => state.set(next));
|
|
85
|
+
|
|
86
|
+
// A computed, not a raw call: the effect below then re-runs only when the
|
|
87
|
+
// inputs actually recompute to something different, and LiveSlot ignores
|
|
88
|
+
// the ones that recompute to the same thing.
|
|
89
|
+
const target = computed(() => inputs());
|
|
90
|
+
|
|
91
|
+
function reconcile(): void {
|
|
92
|
+
slot.point(resource, target());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// LiveSlot.point() writes the output signal synchronously. Angular 18
|
|
97
|
+
// forbids that inside an effect unless allowSignalWrites is set.
|
|
98
|
+
effect(() => reconcile(), {
|
|
99
|
+
allowSignalWrites: true,
|
|
100
|
+
injector: options.injector
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// bun:test has no EffectScheduler. Any other construction failure is real.
|
|
104
|
+
if (!isMissingEffectScheduler(error)) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
destroyRef.onDestroy(() => slot.close());
|
|
110
|
+
|
|
111
|
+
const output = state.asReadonly();
|
|
112
|
+
reconcilers.set(output, reconcile);
|
|
113
|
+
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { PatchEngine } from '../patch/PatchEngine';
|
|
2
|
+
import { canonical } from '../shared/canonical';
|
|
3
|
+
import type { LiveInputs } from '../shared/inputs';
|
|
4
|
+
import {
|
|
5
|
+
LIVE_PROTOCOL_VERSION,
|
|
6
|
+
type ClientMessage,
|
|
7
|
+
type ServerMessage
|
|
8
|
+
} from '../shared/protocol';
|
|
9
|
+
import {
|
|
10
|
+
LadderTransport,
|
|
11
|
+
PollingTransport,
|
|
12
|
+
SseClientTransport,
|
|
13
|
+
WebSocketTransport,
|
|
14
|
+
routeIndex,
|
|
15
|
+
type ClientTransport
|
|
16
|
+
} from './transport';
|
|
17
|
+
|
|
18
|
+
export interface LiveState<T> {
|
|
19
|
+
data: T | undefined;
|
|
20
|
+
pending: boolean;
|
|
21
|
+
error: string | null;
|
|
22
|
+
/** The server cannot vouch for this being current. Data still shown. */
|
|
23
|
+
stale: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LiveStore<T> {
|
|
27
|
+
subscribe(listener: () => void): () => void;
|
|
28
|
+
getSnapshot(): LiveState<T>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The slice of WebSocket this client uses, and the seam tests inject through. */
|
|
32
|
+
export interface LiveSocket {
|
|
33
|
+
send(data: string): void;
|
|
34
|
+
close(): void;
|
|
35
|
+
onopen: (() => void) | null;
|
|
36
|
+
onmessage: ((event: { data: string }) => void) | null;
|
|
37
|
+
onclose: (() => void) | null;
|
|
38
|
+
onerror: ((error: unknown) => void) | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LiveClientOptions {
|
|
42
|
+
url: string;
|
|
43
|
+
token?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Server-rendered payloads, keyed by `${resource}|${canonical(inputs)}`.
|
|
46
|
+
* Lets the first paint skip the waterfall: the store starts full and the
|
|
47
|
+
* subscription only says "this is the hash I already have".
|
|
48
|
+
*/
|
|
49
|
+
hydrate?: Record<string, { data: unknown; hash: string }>;
|
|
50
|
+
unsubGraceMs?: number;
|
|
51
|
+
reconnect?: { initialMs?: number; maxMs?: number };
|
|
52
|
+
socketFactory?: (url: string) => LiveSocket;
|
|
53
|
+
/**
|
|
54
|
+
* Builds the pipe. Defaults to the WebSocket → SSE → polling ladder.
|
|
55
|
+
* `socketFactory` still works and is the shorthand for "same ladder,
|
|
56
|
+
* different socket" that the tests use.
|
|
57
|
+
*/
|
|
58
|
+
transportFactory?: (url: string) => ClientTransport;
|
|
59
|
+
/**
|
|
60
|
+
* The `routes` object the @carno.js/client codegen emits. Only the polling
|
|
61
|
+
* rung needs it -- without it the ladder stops at SSE, and says so.
|
|
62
|
+
*/
|
|
63
|
+
routes?: unknown;
|
|
64
|
+
pollIntervalMs?: number;
|
|
65
|
+
transportProbeMs?: number;
|
|
66
|
+
/** Origin the SSE and polling rungs call. Defaults to the page's own. */
|
|
67
|
+
httpBaseUrl?: string;
|
|
68
|
+
/** SSE downstream path. Must match the server's `config.ssePath`. */
|
|
69
|
+
ssePath?: string;
|
|
70
|
+
/** SSE control path. Must match the server's `config.sseControlPath`. */
|
|
71
|
+
sseControlPath?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Entry {
|
|
75
|
+
sid: string;
|
|
76
|
+
key: string;
|
|
77
|
+
resource: string;
|
|
78
|
+
inputs: LiveInputs;
|
|
79
|
+
refs: number;
|
|
80
|
+
revision: number;
|
|
81
|
+
hash: string | null;
|
|
82
|
+
patcher: PatchEngine;
|
|
83
|
+
/** What the server last told us. Patches apply here, never to the projection. */
|
|
84
|
+
confirmed: unknown;
|
|
85
|
+
state: LiveState<unknown>;
|
|
86
|
+
listeners: Set<() => void>;
|
|
87
|
+
dropTimer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
terminal: boolean;
|
|
89
|
+
store: LiveStore<unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Overlay {
|
|
93
|
+
resource: string;
|
|
94
|
+
apply: (draft: unknown) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_UNSUB_GRACE_MS = 5000;
|
|
98
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 250;
|
|
99
|
+
const DEFAULT_MAX_BACKOFF_MS = 30000;
|
|
100
|
+
|
|
101
|
+
export class LiveClient {
|
|
102
|
+
private readonly entries = new Map<string, Entry>();
|
|
103
|
+
private readonly bySid = new Map<string, Entry>();
|
|
104
|
+
private readonly overlays = new Map<number, Overlay>();
|
|
105
|
+
private nextOverlay = 0;
|
|
106
|
+
private pipe: ClientTransport | null = null;
|
|
107
|
+
private connected = false;
|
|
108
|
+
private closed = false;
|
|
109
|
+
private attempt = 0;
|
|
110
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
private nextSid = 0;
|
|
112
|
+
|
|
113
|
+
constructor(private readonly options: LiveClientOptions) {}
|
|
114
|
+
|
|
115
|
+
store<T>(resource: string, inputs: LiveInputs): LiveStore<T> {
|
|
116
|
+
const key = storeKey(resource, inputs);
|
|
117
|
+
const existing = this.entries.get(key);
|
|
118
|
+
|
|
119
|
+
if (existing) {
|
|
120
|
+
return existing.store as LiveStore<T>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const hydrated = this.options.hydrate?.[key];
|
|
124
|
+
const entry: Entry = {
|
|
125
|
+
sid: `s${this.nextSid++}`,
|
|
126
|
+
key,
|
|
127
|
+
resource,
|
|
128
|
+
inputs,
|
|
129
|
+
refs: 0,
|
|
130
|
+
revision: hydrated ? 1 : 0,
|
|
131
|
+
hash: hydrated?.hash ?? null,
|
|
132
|
+
patcher: new PatchEngine(),
|
|
133
|
+
confirmed: hydrated?.data,
|
|
134
|
+
state: {
|
|
135
|
+
data: hydrated?.data,
|
|
136
|
+
pending: hydrated === undefined,
|
|
137
|
+
error: null,
|
|
138
|
+
stale: false
|
|
139
|
+
},
|
|
140
|
+
listeners: new Set(),
|
|
141
|
+
dropTimer: null,
|
|
142
|
+
terminal: false,
|
|
143
|
+
store: undefined as unknown as LiveStore<unknown>
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
entry.store = {
|
|
147
|
+
subscribe: (listener: () => void) => this.retain(entry, listener),
|
|
148
|
+
getSnapshot: () => entry.state
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
this.entries.set(key, entry);
|
|
152
|
+
this.bySid.set(entry.sid, entry);
|
|
153
|
+
|
|
154
|
+
return entry.store as LiveStore<T>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Show something the server has not confirmed yet.
|
|
159
|
+
*
|
|
160
|
+
* The overlay is a projection over the confirmed snapshot, never a write
|
|
161
|
+
* into it: a patch arriving while the action is in flight lands on the
|
|
162
|
+
* snapshot and the overlay is re-projected on top, so the screen never
|
|
163
|
+
* flickers back to a state the server does not know about.
|
|
164
|
+
*
|
|
165
|
+
* `apply` receives a mutable draft: mutate it, do not return a new value.
|
|
166
|
+
* Returns the function that removes the overlay.
|
|
167
|
+
*/
|
|
168
|
+
overlay(resourceId: string, apply: (draft: any) => void): () => void {
|
|
169
|
+
const id = this.nextOverlay++;
|
|
170
|
+
this.overlays.set(id, { resource: resourceId, apply: apply as (draft: unknown) => void });
|
|
171
|
+
this.reproject(resourceId);
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
if (this.overlays.delete(id)) {
|
|
175
|
+
this.reproject(resourceId);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
close(): void {
|
|
181
|
+
this.closed = true;
|
|
182
|
+
|
|
183
|
+
if (this.reconnectTimer) {
|
|
184
|
+
clearTimeout(this.reconnectTimer);
|
|
185
|
+
this.reconnectTimer = null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.pipe?.close();
|
|
189
|
+
this.pipe = null;
|
|
190
|
+
this.connected = false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Which pipe is carrying this client right now. For logs, not for logic. */
|
|
194
|
+
transport(): ClientTransport['kind'] | null {
|
|
195
|
+
return this.connected ? (this.pipe?.kind ?? null) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ------------------------------------------------------------ lifecycle
|
|
199
|
+
|
|
200
|
+
private retain(entry: Entry, listener: () => void): () => void {
|
|
201
|
+
entry.listeners.add(listener);
|
|
202
|
+
entry.refs += 1;
|
|
203
|
+
|
|
204
|
+
if (entry.dropTimer) {
|
|
205
|
+
clearTimeout(entry.dropTimer);
|
|
206
|
+
entry.dropTimer = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (entry.refs === 1) {
|
|
210
|
+
const openedSynchronously = this.ensureConnected();
|
|
211
|
+
|
|
212
|
+
if (!openedSynchronously) {
|
|
213
|
+
this.sendSub(entry);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return () => {
|
|
218
|
+
entry.listeners.delete(listener);
|
|
219
|
+
entry.refs -= 1;
|
|
220
|
+
|
|
221
|
+
if (entry.terminal || entry.refs > 0 || entry.dropTimer) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Grace period: coming back from a navigation must not tear the
|
|
226
|
+
// subscription down and build it again.
|
|
227
|
+
entry.dropTimer = setTimeout(() => {
|
|
228
|
+
entry.dropTimer = null;
|
|
229
|
+
|
|
230
|
+
if (entry.refs > 0) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.send({ t: 'unsub', sid: entry.sid });
|
|
235
|
+
this.entries.delete(entry.key);
|
|
236
|
+
this.bySid.delete(entry.sid);
|
|
237
|
+
}, this.options.unsubGraceMs ?? DEFAULT_UNSUB_GRACE_MS);
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private ensureConnected(): boolean {
|
|
242
|
+
if (this.pipe || this.closed) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const build = this.options.transportFactory ?? ((url: string) => this.buildLadder(url));
|
|
247
|
+
|
|
248
|
+
const pipe = build(this.options.url);
|
|
249
|
+
this.pipe = pipe;
|
|
250
|
+
let openedSynchronously = false;
|
|
251
|
+
|
|
252
|
+
pipe.start({
|
|
253
|
+
onOpen: () => {
|
|
254
|
+
openedSynchronously = true;
|
|
255
|
+
this.connected = true;
|
|
256
|
+
this.attempt = 0;
|
|
257
|
+
this.send({ t: 'hello', v: LIVE_PROTOCOL_VERSION, token: this.options.token });
|
|
258
|
+
|
|
259
|
+
// Reconnect is just "subscribe again, carrying the hash of
|
|
260
|
+
// what is on screen". There is no session to restore, because
|
|
261
|
+
// there is no session.
|
|
262
|
+
for (const entry of this.entries.values()) {
|
|
263
|
+
if (entry.refs > 0) {
|
|
264
|
+
this.sendSub(entry);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
onMessage: raw => this.onMessage(raw),
|
|
269
|
+
onClose: () => this.onDisconnect()
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return openedSynchronously;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* WebSocket, then SSE, then polling. Rungs whose prerequisites are missing
|
|
277
|
+
* are not offered: a polling rung with no route index would fail every
|
|
278
|
+
* subscription with the same error.
|
|
279
|
+
*/
|
|
280
|
+
private buildLadder(url: string): ClientTransport {
|
|
281
|
+
const origin = this.options.httpBaseUrl
|
|
282
|
+
?? url.replace(/^ws/, 'http').replace(/\/live\/?$/, '');
|
|
283
|
+
const index = routeIndex(this.options.routes);
|
|
284
|
+
const rungs: (() => ClientTransport)[] = [
|
|
285
|
+
() => new WebSocketTransport(url, this.options.socketFactory),
|
|
286
|
+
() => new SseClientTransport(origin, {
|
|
287
|
+
streamPath: this.options.ssePath,
|
|
288
|
+
controlPath: this.options.sseControlPath
|
|
289
|
+
})
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
if (Object.keys(index).length > 0) {
|
|
293
|
+
rungs.push(() => new PollingTransport(origin, index, {
|
|
294
|
+
intervalMs: this.options.pollIntervalMs,
|
|
295
|
+
token: this.options.token
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return new LadderTransport(rungs, { probeMs: this.options.transportProbeMs ?? 3000 });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private onDisconnect(): void {
|
|
303
|
+
this.connected = false;
|
|
304
|
+
this.pipe = null;
|
|
305
|
+
|
|
306
|
+
if (this.closed || this.reconnectTimer) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const initial = this.options.reconnect?.initialMs ?? DEFAULT_INITIAL_BACKOFF_MS;
|
|
311
|
+
const max = this.options.reconnect?.maxMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
312
|
+
const ceiling = Math.min(max, initial * 2 ** this.attempt);
|
|
313
|
+
|
|
314
|
+
// Full jitter, and it is mandatory: a deploy reconnects every client at
|
|
315
|
+
// once, and a synchronised recompute storm takes the database down.
|
|
316
|
+
const delay = Math.random() * ceiling;
|
|
317
|
+
this.attempt += 1;
|
|
318
|
+
|
|
319
|
+
this.reconnectTimer = setTimeout(() => {
|
|
320
|
+
this.reconnectTimer = null;
|
|
321
|
+
this.ensureConnected();
|
|
322
|
+
}, delay);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private sendSub(entry: Entry): void {
|
|
326
|
+
this.send({
|
|
327
|
+
t: 'sub',
|
|
328
|
+
sid: entry.sid,
|
|
329
|
+
resource: entry.resource,
|
|
330
|
+
inputs: entry.inputs,
|
|
331
|
+
hash: entry.hash ?? undefined
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private send(message: ClientMessage): void {
|
|
336
|
+
if (!this.pipe || !this.connected) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
this.pipe.send(JSON.stringify(message));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// -------------------------------------------------------------- inbound
|
|
344
|
+
|
|
345
|
+
private onMessage(raw: string): void {
|
|
346
|
+
let message: ServerMessage;
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
message = JSON.parse(raw) as ServerMessage;
|
|
350
|
+
} catch {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const entry = this.bySid.get((message as { sid?: string }).sid ?? '');
|
|
355
|
+
|
|
356
|
+
if (!entry) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
switch (message.t) {
|
|
361
|
+
case 'snapshot':
|
|
362
|
+
if (message.key) {
|
|
363
|
+
entry.patcher = new PatchEngine(message.key);
|
|
364
|
+
}
|
|
365
|
+
entry.revision = message.rev;
|
|
366
|
+
entry.hash = message.hash;
|
|
367
|
+
entry.confirmed = message.data;
|
|
368
|
+
this.project(entry, { pending: false, error: null, stale: false });
|
|
369
|
+
return;
|
|
370
|
+
|
|
371
|
+
case 'current':
|
|
372
|
+
if (message.key) {
|
|
373
|
+
entry.patcher = new PatchEngine(message.key);
|
|
374
|
+
}
|
|
375
|
+
entry.revision = message.rev;
|
|
376
|
+
entry.hash = message.hash;
|
|
377
|
+
// Content already on screen: touch only the flags, keep the
|
|
378
|
+
// data referentially identical so nothing re-renders.
|
|
379
|
+
this.project(entry, { pending: false, error: null, stale: false });
|
|
380
|
+
return;
|
|
381
|
+
|
|
382
|
+
case 'patch':
|
|
383
|
+
if (message.from !== entry.revision) {
|
|
384
|
+
// A hole in the sequence. Ask for full state rather than
|
|
385
|
+
// applying ops to a base we cannot vouch for.
|
|
386
|
+
this.send({ t: 'resync', sid: entry.sid, hash: entry.hash ?? undefined });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
entry.revision = message.to;
|
|
391
|
+
entry.hash = message.hash;
|
|
392
|
+
entry.confirmed = entry.patcher.apply(entry.confirmed, message.ops);
|
|
393
|
+
this.project(entry, { pending: false, error: null, stale: false });
|
|
394
|
+
return;
|
|
395
|
+
|
|
396
|
+
case 'stale':
|
|
397
|
+
this.update(entry, { ...entry.state, stale: true });
|
|
398
|
+
return;
|
|
399
|
+
|
|
400
|
+
case 'error':
|
|
401
|
+
this.terminate(entry);
|
|
402
|
+
this.update(entry, { ...entry.state, pending: false, error: message.message });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A server error ends this subscription; keep its store state, not its binding. */
|
|
408
|
+
private terminate(entry: Entry): void {
|
|
409
|
+
entry.terminal = true;
|
|
410
|
+
|
|
411
|
+
if (entry.dropTimer) {
|
|
412
|
+
clearTimeout(entry.dropTimer);
|
|
413
|
+
entry.dropTimer = null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (this.entries.get(entry.key) === entry) {
|
|
417
|
+
this.entries.delete(entry.key);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.bySid.get(entry.sid) === entry) {
|
|
421
|
+
this.bySid.delete(entry.sid);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Confirmed snapshot plus every overlay registered for this resource. */
|
|
426
|
+
private project(
|
|
427
|
+
entry: Entry,
|
|
428
|
+
flags: { pending: boolean; error: string | null; stale: boolean }
|
|
429
|
+
): void {
|
|
430
|
+
const overlays = [...this.overlays.values()].filter(overlay => overlay.resource === entry.resource);
|
|
431
|
+
|
|
432
|
+
if (overlays.length === 0 || entry.confirmed === undefined) {
|
|
433
|
+
this.update(entry, { ...flags, data: entry.confirmed });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const draft = structuredClone(entry.confirmed);
|
|
438
|
+
|
|
439
|
+
for (const overlay of overlays) {
|
|
440
|
+
try {
|
|
441
|
+
overlay.apply(draft);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
// A broken optimistic update must not take the real data with
|
|
444
|
+
// it: the confirmed snapshot is still correct underneath.
|
|
445
|
+
console.error('[carno:live] an optimistic overlay failed', error);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
this.update(entry, { ...flags, data: draft });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private reproject(resourceId: string): void {
|
|
453
|
+
for (const entry of this.entries.values()) {
|
|
454
|
+
if (entry.resource !== resourceId) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.project(entry, {
|
|
459
|
+
pending: entry.state.pending,
|
|
460
|
+
error: entry.state.error,
|
|
461
|
+
stale: entry.state.stale
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private update(entry: Entry, next: LiveState<unknown>): void {
|
|
467
|
+
if (
|
|
468
|
+
next.data === entry.state.data &&
|
|
469
|
+
next.pending === entry.state.pending &&
|
|
470
|
+
next.error === entry.state.error &&
|
|
471
|
+
next.stale === entry.state.stale
|
|
472
|
+
) {
|
|
473
|
+
// Nothing changed. Keeping the same object is what makes
|
|
474
|
+
// useSyncExternalStore stable instead of looping.
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
entry.state = next;
|
|
479
|
+
|
|
480
|
+
for (const listener of entry.listeners) {
|
|
481
|
+
listener();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function storeKey(resource: string, inputs: LiveInputs): string {
|
|
487
|
+
return `${resource}|${canonical({
|
|
488
|
+
params: inputs.params ?? {},
|
|
489
|
+
query: inputs.query ?? {},
|
|
490
|
+
body: inputs.body ?? null
|
|
491
|
+
})}`;
|
|
492
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { LivePayload } from '../resource/prefetch';
|
|
2
|
+
import type { LiveInputs } from '../shared/inputs';
|
|
3
|
+
import { storeKey } from './core';
|
|
4
|
+
|
|
5
|
+
/** Attribute the island helper marks its payload scripts with. */
|
|
6
|
+
export const HYDRATION_ATTRIBUTE = 'data-carno-live';
|
|
7
|
+
|
|
8
|
+
/** Exactly the key `LiveClient.store()` will look up. */
|
|
9
|
+
export function hydrationKey(payload: LivePayload): string {
|
|
10
|
+
return storeKey(payload.resourceId, payload.inputs);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toHydrateMap(payloads: LivePayload[]): Record<string, { data: unknown; hash: string }> {
|
|
14
|
+
const map: Record<string, { data: unknown; hash: string }> = {};
|
|
15
|
+
|
|
16
|
+
for (const payload of payloads) {
|
|
17
|
+
if (!isLivePayload(payload)) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
map[hydrationKey(payload)] = { data: payload.data, hash: payload.hash };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collect every island payload the server embedded in the page.
|
|
29
|
+
*
|
|
30
|
+
* A malformed one is skipped rather than thrown: one broken island must not
|
|
31
|
+
* cost the page every other island's first paint.
|
|
32
|
+
*/
|
|
33
|
+
export function readHydrationPayload(
|
|
34
|
+
root: ParentNode = document
|
|
35
|
+
): Record<string, { data: unknown; hash: string }> {
|
|
36
|
+
const payloads: LivePayload[] = [];
|
|
37
|
+
|
|
38
|
+
for (const node of root.querySelectorAll(`script[${HYDRATION_ATTRIBUTE}]`)) {
|
|
39
|
+
try {
|
|
40
|
+
payloads.push(JSON.parse(node.textContent ?? '') as LivePayload);
|
|
41
|
+
} catch {
|
|
42
|
+
// Skip it. The client will fetch that one instead.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return toHydrateMap(payloads);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isLivePayload(value: unknown): value is LivePayload {
|
|
50
|
+
if (!isRecord(value) || typeof value.resourceId !== 'string' || value.resourceId.length === 0) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return typeof value.hash === 'string'
|
|
55
|
+
&& value.hash.length > 0
|
|
56
|
+
&& isLiveInputs(value.inputs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLiveInputs(value: unknown): value is LiveInputs {
|
|
60
|
+
if (!isRecord(value)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const params = value.params;
|
|
65
|
+
const query = value.query;
|
|
66
|
+
|
|
67
|
+
if (!isRecord(params) || !isRecord(query)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return Object.values(params).every(item => typeof item === 'string')
|
|
72
|
+
&& Object.values(query).every(item =>
|
|
73
|
+
typeof item === 'string'
|
|
74
|
+
|| (Array.isArray(item) && item.every(value => typeof value === 'string'))
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LiveDataOf, LiveDescriptor } from '../shared/descriptor';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One optimistic projection: which resource it targets, and how the action's
|
|
5
|
+
* payload changes it.
|
|
6
|
+
*
|
|
7
|
+
* `on` is what makes `draft` typed. Without naming the target, the draft would
|
|
8
|
+
* have to be `any`, and an optimistic update on `any` is a guess the compiler
|
|
9
|
+
* cannot check.
|
|
10
|
+
*/
|
|
11
|
+
export interface OptimisticEntry<Target, Dto> {
|
|
12
|
+
on: Target;
|
|
13
|
+
apply: (draft: LiveDataOf<Target extends LiveDescriptor<infer R> ? R : never>, dto: Dto) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Maps a tuple of descriptors to the matching tuple of optimistic entries. */
|
|
17
|
+
export type OptimisticList<Targets extends readonly unknown[], Dto> = {
|
|
18
|
+
[K in keyof Targets]: OptimisticEntry<Targets[K], Dto>;
|
|
19
|
+
};
|