@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,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
type ReactNode
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { canonical } from '../shared/canonical';
|
|
13
|
+
import {
|
|
14
|
+
normalizeLiveInputs,
|
|
15
|
+
resourceIdOf,
|
|
16
|
+
type LiveDataOf,
|
|
17
|
+
type LiveDescriptor,
|
|
18
|
+
type LiveInputsOf
|
|
19
|
+
} from '../shared/descriptor';
|
|
20
|
+
import type { LiveInputs } from '../shared/inputs';
|
|
21
|
+
import type { LiveClient, LiveState } from './core';
|
|
22
|
+
import type { OptimisticList } from './optimistic';
|
|
23
|
+
|
|
24
|
+
export const LiveContext = createContext<LiveClient | null>(null);
|
|
25
|
+
|
|
26
|
+
export function LiveProvider(props: { client: LiveClient; children?: ReactNode }): ReactElement {
|
|
27
|
+
return createElement(LiveContext.Provider, { value: props.client }, props.children);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useLive<T>(resource: string, inputs?: Partial<LiveInputs>): LiveState<T>;
|
|
31
|
+
export function useLive<R>(
|
|
32
|
+
descriptor: LiveDescriptor<R>,
|
|
33
|
+
inputs?: LiveInputsOf<R>
|
|
34
|
+
): LiveState<LiveDataOf<R>>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Subscribe a component to server-owned state.
|
|
38
|
+
*
|
|
39
|
+
* The component keeps its own local state next to this — selected row, open
|
|
40
|
+
* modal, focused input. None of that travels; only the server's data does.
|
|
41
|
+
*/
|
|
42
|
+
export function useLive(
|
|
43
|
+
resource: string | LiveDescriptor<any>,
|
|
44
|
+
// Loose on purpose, and invisible to callers: a descriptor's own `query`
|
|
45
|
+
// type is a plain object, which no index signature accepts. The overloads
|
|
46
|
+
// above are what anyone actually sees.
|
|
47
|
+
inputs: Record<string, any> = {}
|
|
48
|
+
): LiveState<any> {
|
|
49
|
+
const client = useContext(LiveContext);
|
|
50
|
+
|
|
51
|
+
if (!client) {
|
|
52
|
+
throw new Error('useLive() requires a <LiveProvider client={...}> above it in the tree.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resourceId = typeof resource === 'string' ? resource : resourceIdOf(resource);
|
|
56
|
+
const normalized = normalizeLiveInputs(inputs as Partial<LiveInputs>);
|
|
57
|
+
const identity = canonical({
|
|
58
|
+
params: normalized.params,
|
|
59
|
+
query: normalized.query,
|
|
60
|
+
body: normalized.body ?? null
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Depend on the canonical form, not on the object: a new literal every
|
|
64
|
+
// render would resubscribe on every render.
|
|
65
|
+
const stable = useMemo(() => normalized, [identity]);
|
|
66
|
+
const store = useMemo(() => client.store(resourceId, stable), [client, resourceId, stable]);
|
|
67
|
+
|
|
68
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run an action, showing its expected effect immediately.
|
|
73
|
+
*
|
|
74
|
+
* The overlay lives above the confirmed snapshot, so a server patch arriving
|
|
75
|
+
* mid-flight lands underneath it and nothing flickers. It is removed when the
|
|
76
|
+
* action settles, either way.
|
|
77
|
+
*/
|
|
78
|
+
export function useLiveAction<
|
|
79
|
+
Dto,
|
|
80
|
+
Result,
|
|
81
|
+
const Targets extends readonly LiveDescriptor<any>[]
|
|
82
|
+
>(
|
|
83
|
+
action: (dto: Dto) => Promise<Result>,
|
|
84
|
+
options: { optimistic?: OptimisticList<Targets, Dto> } = {}
|
|
85
|
+
): (dto: Dto) => Promise<Result> {
|
|
86
|
+
const client = useContext(LiveContext);
|
|
87
|
+
|
|
88
|
+
if (!client) {
|
|
89
|
+
throw new Error('useLiveAction() requires a <LiveProvider client={...}> above it in the tree.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The array is a fresh literal on every render; a ref keeps the returned
|
|
93
|
+
// function stable without making the dependency list lie.
|
|
94
|
+
const specs = useRef<readonly { on: LiveDescriptor<any>; apply: (draft: any, dto: Dto) => void }[]>([]);
|
|
95
|
+
specs.current = (options.optimistic ?? []) as readonly {
|
|
96
|
+
on: LiveDescriptor<any>;
|
|
97
|
+
apply: (draft: any, dto: Dto) => void;
|
|
98
|
+
}[];
|
|
99
|
+
|
|
100
|
+
return useCallback(async (dto: Dto): Promise<Result> => {
|
|
101
|
+
const remove = specs.current.map(spec =>
|
|
102
|
+
client.overlay(resourceIdOf(spec.on), draft => spec.apply(draft, dto))
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await action(dto);
|
|
107
|
+
} finally {
|
|
108
|
+
for (const drop of remove) {
|
|
109
|
+
drop();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}, [client, action]);
|
|
113
|
+
}
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import type { LiveSocket } from './core';
|
|
2
|
+
import {
|
|
3
|
+
LIVE_CONNECTION_HEADER,
|
|
4
|
+
LIVE_POLL_HEADER,
|
|
5
|
+
LIVE_RESOURCE_HEADER,
|
|
6
|
+
LIVE_TOKEN_HEADER,
|
|
7
|
+
type ClientMessage,
|
|
8
|
+
type ServerMessage
|
|
9
|
+
} from '../shared/protocol';
|
|
10
|
+
|
|
11
|
+
export interface TransportHandlers {
|
|
12
|
+
onOpen(): void;
|
|
13
|
+
onMessage(raw: string): void;
|
|
14
|
+
onClose(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A pipe the client can speak the protocol over.
|
|
19
|
+
*
|
|
20
|
+
* The protocol does not change between implementations, and neither does the
|
|
21
|
+
* client's behaviour: a component cannot tell which one is in use, and the day
|
|
22
|
+
* an `if (kind === 'sse')` appears in a component, the abstraction has failed.
|
|
23
|
+
* `kind` exists to be logged.
|
|
24
|
+
*/
|
|
25
|
+
export interface ClientTransport {
|
|
26
|
+
readonly kind: 'websocket' | 'sse' | 'polling';
|
|
27
|
+
start(handlers: TransportHandlers): void;
|
|
28
|
+
send(raw: string): void;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class WebSocketTransport implements ClientTransport {
|
|
33
|
+
readonly kind = 'websocket' as const;
|
|
34
|
+
|
|
35
|
+
private socket: LiveSocket | null = null;
|
|
36
|
+
private closed = false;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly url: string,
|
|
40
|
+
private readonly factory: (url: string) => LiveSocket = defaultSocketFactory
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
start(handlers: TransportHandlers): void {
|
|
44
|
+
const socket = this.factory(this.url);
|
|
45
|
+
this.socket = socket;
|
|
46
|
+
|
|
47
|
+
socket.onopen = () => handlers.onOpen();
|
|
48
|
+
socket.onmessage = event => handlers.onMessage(event.data);
|
|
49
|
+
// An error and a close both mean the same thing here: the pipe is gone.
|
|
50
|
+
socket.onclose = () => this.report(handlers);
|
|
51
|
+
socket.onerror = () => this.report(handlers);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
send(raw: string): void {
|
|
55
|
+
this.socket?.send(raw);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
close(): void {
|
|
59
|
+
this.closed = true;
|
|
60
|
+
this.socket?.close();
|
|
61
|
+
this.socket = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private report(handlers: TransportHandlers): void {
|
|
65
|
+
if (this.closed) {
|
|
66
|
+
// We closed it. Reporting it would schedule a reconnect to a
|
|
67
|
+
// client that has already given up.
|
|
68
|
+
// Also latches error-then-close so the ladder sees one death,
|
|
69
|
+
// not two descents that skip a rung.
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.closed = true;
|
|
74
|
+
this.socket = null;
|
|
75
|
+
handlers.onClose();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultSocketFactory(url: string): LiveSocket {
|
|
80
|
+
return new WebSocket(url) as unknown as LiveSocket;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RoutePath {
|
|
84
|
+
method: string;
|
|
85
|
+
path: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface EventSourceLike {
|
|
89
|
+
onopen: ((event: unknown) => void) | null;
|
|
90
|
+
onmessage: ((event: { data: string }) => void) | null;
|
|
91
|
+
onerror: ((event: unknown) => void) | null;
|
|
92
|
+
close(): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Flatten the tree `@carno.js/client` generates into `resourceId -> path`.
|
|
97
|
+
*
|
|
98
|
+
* Polling needs a URL and the protocol carries a resource id, so without this
|
|
99
|
+
* the bottom rung cannot exist. Routes with no `@Live()` are skipped: polling
|
|
100
|
+
* one would be polling something nobody can subscribe to.
|
|
101
|
+
*/
|
|
102
|
+
export function routeIndex(routes: unknown): Record<string, RoutePath> {
|
|
103
|
+
const index: Record<string, RoutePath> = {};
|
|
104
|
+
|
|
105
|
+
const walk = (node: unknown): void => {
|
|
106
|
+
if (!node || typeof node !== 'object') {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const candidate = node as { method?: unknown; path?: unknown; resourceId?: unknown; live?: unknown };
|
|
111
|
+
|
|
112
|
+
if (typeof candidate.method === 'string' && typeof candidate.path === 'string') {
|
|
113
|
+
if (
|
|
114
|
+
candidate.method.toLowerCase() === 'get' &&
|
|
115
|
+
typeof candidate.resourceId === 'string' &&
|
|
116
|
+
candidate.live
|
|
117
|
+
) {
|
|
118
|
+
index[candidate.resourceId] = { method: candidate.method, path: candidate.path };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const value of Object.values(node as Record<string, unknown>)) {
|
|
125
|
+
walk(value);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
walk(routes);
|
|
130
|
+
|
|
131
|
+
return index;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Try each rung in order, descend when one does not open.
|
|
136
|
+
*
|
|
137
|
+
* There is no promotion back up: a proxy that blocks WebSocket will keep
|
|
138
|
+
* blocking it, and retrying the top rung on every reconnect spends a round
|
|
139
|
+
* trip per cycle to fail forever. The next page load starts at the top again.
|
|
140
|
+
*/
|
|
141
|
+
export class LadderTransport implements ClientTransport {
|
|
142
|
+
private active: ClientTransport | null = null;
|
|
143
|
+
private handlers: TransportHandlers | null = null;
|
|
144
|
+
private rung = 0;
|
|
145
|
+
private settled = false;
|
|
146
|
+
private probe: ReturnType<typeof setTimeout> | null = null;
|
|
147
|
+
private closed = false;
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly rungs: (() => ClientTransport)[],
|
|
151
|
+
private readonly options: { probeMs: number }
|
|
152
|
+
) {}
|
|
153
|
+
|
|
154
|
+
get kind(): ClientTransport['kind'] {
|
|
155
|
+
return this.active?.kind ?? 'websocket';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
start(handlers: TransportHandlers): void {
|
|
159
|
+
this.handlers = handlers;
|
|
160
|
+
this.rung = 0;
|
|
161
|
+
this.settled = false;
|
|
162
|
+
this.tryRung();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
send(raw: string): void {
|
|
166
|
+
this.active?.send(raw);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
close(): void {
|
|
170
|
+
this.closed = true;
|
|
171
|
+
this.clearProbe();
|
|
172
|
+
this.active?.close();
|
|
173
|
+
this.active = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private tryRung(): void {
|
|
177
|
+
this.clearProbe();
|
|
178
|
+
|
|
179
|
+
if (this.closed) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (this.rung >= this.rungs.length) {
|
|
184
|
+
// Every rung refused. One close, not one per rung, or the client
|
|
185
|
+
// schedules a reconnect storm against itself.
|
|
186
|
+
this.handlers?.onClose();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const rung = this.rung;
|
|
191
|
+
const transport = this.rungs[rung]();
|
|
192
|
+
this.active = transport;
|
|
193
|
+
|
|
194
|
+
transport.start({
|
|
195
|
+
onOpen: () => {
|
|
196
|
+
this.clearProbe();
|
|
197
|
+
this.settled = true;
|
|
198
|
+
this.handlers?.onOpen();
|
|
199
|
+
},
|
|
200
|
+
onMessage: raw => this.handlers?.onMessage(raw),
|
|
201
|
+
onClose: () => {
|
|
202
|
+
if (this.active !== transport) {
|
|
203
|
+
// A previous rung firing error then close. One death,
|
|
204
|
+
// one descent — otherwise we skip the rung that just
|
|
205
|
+
// became active.
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (this.settled) {
|
|
210
|
+
// It worked and then dropped. That is an ordinary
|
|
211
|
+
// disconnect and the client's backoff owns it.
|
|
212
|
+
this.handlers?.onClose();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.descend();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// A proxy that swallows the upgrade without answering never fires an
|
|
221
|
+
// error. Without this the ladder would wait for a close that never
|
|
222
|
+
// comes, on the rung that is exactly the one being blocked.
|
|
223
|
+
if (this.active === transport && this.rung === rung && !this.settled && !this.closed) {
|
|
224
|
+
this.probe = setTimeout(() => this.descend(), this.options.probeMs);
|
|
225
|
+
this.probe.unref?.();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private descend(): void {
|
|
230
|
+
if (this.settled || this.closed) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.clearProbe();
|
|
235
|
+
this.active?.close();
|
|
236
|
+
this.active = null;
|
|
237
|
+
this.rung += 1;
|
|
238
|
+
this.tryRung();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private clearProbe(): void {
|
|
242
|
+
if (this.probe) {
|
|
243
|
+
clearTimeout(this.probe);
|
|
244
|
+
this.probe = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The protocol over Server-Sent Events: down the stream, up by POST.
|
|
251
|
+
*
|
|
252
|
+
* The connection id arrives in the first frame and every client message
|
|
253
|
+
* carries it, because the control endpoint has no other way to know which
|
|
254
|
+
* stream a POST belongs to.
|
|
255
|
+
*/
|
|
256
|
+
export class SseClientTransport implements ClientTransport {
|
|
257
|
+
readonly kind = 'sse' as const;
|
|
258
|
+
|
|
259
|
+
private source: EventSourceLike | null = null;
|
|
260
|
+
private cid: string | null = null;
|
|
261
|
+
private queue: string[] = [];
|
|
262
|
+
private closed = false;
|
|
263
|
+
|
|
264
|
+
constructor(
|
|
265
|
+
private readonly baseUrl: string,
|
|
266
|
+
private readonly options: {
|
|
267
|
+
streamPath?: string;
|
|
268
|
+
controlPath?: string;
|
|
269
|
+
fetch?: typeof fetch;
|
|
270
|
+
eventSourceFactory?: (url: string) => EventSourceLike;
|
|
271
|
+
} = {}
|
|
272
|
+
) {}
|
|
273
|
+
|
|
274
|
+
start(handlers: TransportHandlers): void {
|
|
275
|
+
const streamUrl = `${this.baseUrl}${this.options.streamPath ?? '/live/sse'}`;
|
|
276
|
+
|
|
277
|
+
if (!this.options.eventSourceFactory && typeof EventSource === 'undefined') {
|
|
278
|
+
// SSR and Bun do not provide the browser EventSource API. Treat
|
|
279
|
+
// that as an unavailable rung so the ladder can continue to the
|
|
280
|
+
// next transport instead of throwing during construction.
|
|
281
|
+
handlers.onClose();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const build = this.options.eventSourceFactory
|
|
286
|
+
?? ((url: string) => new EventSource(url) as unknown as EventSourceLike);
|
|
287
|
+
|
|
288
|
+
const source = build(streamUrl);
|
|
289
|
+
this.source = source;
|
|
290
|
+
|
|
291
|
+
source.onmessage = event => {
|
|
292
|
+
let frame: { t?: string; cid?: string };
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
frame = JSON.parse(event.data) as { t?: string; cid?: string };
|
|
296
|
+
} catch {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (frame.t === 'ready' && typeof frame.cid === 'string') {
|
|
301
|
+
this.cid = frame.cid;
|
|
302
|
+
handlers.onOpen();
|
|
303
|
+
// Anything the client tried to say before the id arrived.
|
|
304
|
+
const queued = this.queue;
|
|
305
|
+
this.queue = [];
|
|
306
|
+
for (const raw of queued) {
|
|
307
|
+
this.send(raw);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
handlers.onMessage(event.data);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
source.onerror = () => {
|
|
316
|
+
if (this.closed) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this.closed = true;
|
|
321
|
+
this.source?.close();
|
|
322
|
+
this.source = null;
|
|
323
|
+
handlers.onClose();
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
send(raw: string): void {
|
|
328
|
+
if (!this.cid) {
|
|
329
|
+
this.queue.push(raw);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const post = this.options.fetch ?? fetch;
|
|
334
|
+
|
|
335
|
+
void post(`${this.baseUrl}${this.options.controlPath ?? '/live/control'}`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: { 'Content-Type': 'application/json' },
|
|
338
|
+
body: JSON.stringify({ cid: this.cid, message: JSON.parse(raw) as ClientMessage })
|
|
339
|
+
}).catch(() => {
|
|
340
|
+
// A failed control POST is not a dead stream. The stream's own
|
|
341
|
+
// error handler owns the disconnect.
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
close(): void {
|
|
346
|
+
this.closed = true;
|
|
347
|
+
this.source?.close();
|
|
348
|
+
this.source = null;
|
|
349
|
+
this.cid = null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
interface Poll {
|
|
354
|
+
sid: string;
|
|
355
|
+
resourceId: string;
|
|
356
|
+
url: string;
|
|
357
|
+
etag: string | null;
|
|
358
|
+
revision: number;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The floor: conditional GET on the route the resource already serves.
|
|
363
|
+
*
|
|
364
|
+
* There are no patches here, only snapshots and 304s. A patch would need the
|
|
365
|
+
* server to remember this client's previous revision, which is precisely the
|
|
366
|
+
* history §8.1 says does not exist.
|
|
367
|
+
*/
|
|
368
|
+
export class PollingTransport implements ClientTransport {
|
|
369
|
+
readonly kind = 'polling' as const;
|
|
370
|
+
|
|
371
|
+
private handlers: TransportHandlers | null = null;
|
|
372
|
+
private readonly polls = new Map<string, Poll>();
|
|
373
|
+
private readonly connectionId = pollingConnectionId();
|
|
374
|
+
private token: string | undefined;
|
|
375
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
376
|
+
|
|
377
|
+
constructor(
|
|
378
|
+
private readonly baseUrl: string,
|
|
379
|
+
private readonly routes: Record<string, RoutePath>,
|
|
380
|
+
private readonly options: { intervalMs?: number; fetch?: typeof fetch; token?: string } = {}
|
|
381
|
+
) {
|
|
382
|
+
this.token = options.token;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
start(handlers: TransportHandlers): void {
|
|
386
|
+
this.handlers = handlers;
|
|
387
|
+
handlers.onOpen();
|
|
388
|
+
|
|
389
|
+
const interval = this.options.intervalMs ?? 5000;
|
|
390
|
+
this.timer = setInterval(() => this.tick(), interval);
|
|
391
|
+
this.timer.unref?.();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
send(raw: string): void {
|
|
395
|
+
let message: ClientMessage;
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
message = JSON.parse(raw) as ClientMessage;
|
|
399
|
+
} catch {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (message.t === 'unsub') {
|
|
404
|
+
this.polls.delete(message.sid);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (message.t === 'hello') {
|
|
409
|
+
this.token = message.token;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (message.t !== 'sub') {
|
|
414
|
+
// `resync` is answered by the next tick anyway.
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const route = this.routes[message.resource];
|
|
419
|
+
|
|
420
|
+
if (!route) {
|
|
421
|
+
this.emit({
|
|
422
|
+
t: 'error',
|
|
423
|
+
sid: message.sid,
|
|
424
|
+
code: 'no_route',
|
|
425
|
+
message:
|
|
426
|
+
`Polling cannot reach "${message.resource}": pass the generated \`routes\` ` +
|
|
427
|
+
`to the LiveClient so it can turn a resource id into a URL.`
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const poll: Poll = {
|
|
433
|
+
sid: message.sid,
|
|
434
|
+
resourceId: message.resource,
|
|
435
|
+
url: buildUrl(this.baseUrl, route.path, message.inputs),
|
|
436
|
+
etag: message.hash ? `"${message.hash}"` : null,
|
|
437
|
+
revision: 0
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
this.polls.set(message.sid, poll);
|
|
441
|
+
void this.fetchOne(poll);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
close(): void {
|
|
445
|
+
if (this.timer) {
|
|
446
|
+
clearInterval(this.timer);
|
|
447
|
+
this.timer = null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
this.polls.clear();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private tick(): void {
|
|
454
|
+
for (const poll of this.polls.values()) {
|
|
455
|
+
void this.fetchOne(poll);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async fetchOne(poll: Poll): Promise<void> {
|
|
460
|
+
const get = this.options.fetch ?? fetch;
|
|
461
|
+
const headers: Record<string, string> = {};
|
|
462
|
+
headers[LIVE_POLL_HEADER] = '1';
|
|
463
|
+
headers[LIVE_CONNECTION_HEADER] = this.connectionId;
|
|
464
|
+
headers[LIVE_RESOURCE_HEADER] = poll.resourceId;
|
|
465
|
+
|
|
466
|
+
if (this.token) {
|
|
467
|
+
headers[LIVE_TOKEN_HEADER] = this.token;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (poll.etag) {
|
|
471
|
+
headers['If-None-Match'] = poll.etag;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let response: Response;
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
response = await get(poll.url, { headers });
|
|
478
|
+
} catch (error) {
|
|
479
|
+
this.emit({ t: 'stale', sid: poll.sid, reason: (error as Error).message });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (response.status === 304) {
|
|
484
|
+
// Already right on screen. Emitting a snapshot would hand the
|
|
485
|
+
// store a new object for identical content and re-render for it.
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
if (response.status === 401 || response.status === 403) {
|
|
491
|
+
this.polls.delete(poll.sid);
|
|
492
|
+
this.emit({
|
|
493
|
+
t: 'error',
|
|
494
|
+
sid: poll.sid,
|
|
495
|
+
code: 'forbidden',
|
|
496
|
+
message: 'Polling authorization failed.'
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (response.status >= 400 && response.status < 500) {
|
|
502
|
+
this.polls.delete(poll.sid);
|
|
503
|
+
this.emit({
|
|
504
|
+
t: 'error',
|
|
505
|
+
sid: poll.sid,
|
|
506
|
+
code: 'invalid_subscription',
|
|
507
|
+
message: `Polling route returned HTTP ${response.status}.`
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
this.emit({ t: 'stale', sid: poll.sid, reason: `HTTP ${response.status}` });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const tag = response.headers.get('ETag');
|
|
517
|
+
poll.etag = tag;
|
|
518
|
+
poll.revision += 1;
|
|
519
|
+
|
|
520
|
+
this.emit({
|
|
521
|
+
t: 'snapshot',
|
|
522
|
+
sid: poll.sid,
|
|
523
|
+
rev: poll.revision,
|
|
524
|
+
hash: tag ? tag.replace(/"/g, '') : '',
|
|
525
|
+
data: await response.json()
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private emit(message: ServerMessage): void {
|
|
530
|
+
this.handlers?.onMessage(JSON.stringify(message));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function pollingConnectionId(): string {
|
|
535
|
+
const uuid = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
536
|
+
? crypto.randomUUID()
|
|
537
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
538
|
+
|
|
539
|
+
return `poll:${uuid}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function buildUrl(baseUrl: string, path: string, inputs: { params?: Record<string, string>; query?: Record<string, unknown> }): string {
|
|
543
|
+
const filled = path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name: string) => {
|
|
544
|
+
const value = inputs.params?.[name];
|
|
545
|
+
|
|
546
|
+
if (value === undefined) {
|
|
547
|
+
throw new Error(`Missing path parameter "${name}" for ${path}.`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return encodeURIComponent(String(value));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const search = new URLSearchParams();
|
|
554
|
+
|
|
555
|
+
for (const [key, value] of Object.entries(inputs.query ?? {})) {
|
|
556
|
+
if (Array.isArray(value)) {
|
|
557
|
+
for (const item of value) {
|
|
558
|
+
search.append(key, String(item));
|
|
559
|
+
}
|
|
560
|
+
} else if (value !== undefined && value !== null) {
|
|
561
|
+
search.set(key, String(value));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const suffix = search.toString();
|
|
566
|
+
|
|
567
|
+
return `${baseUrl}${filled}${suffix ? `?${suffix}` : ''}`;
|
|
568
|
+
}
|