@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,175 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Controller, Get } from '@carno.js/core';
|
|
3
|
+
import { Live } from '../src/decorators/Live';
|
|
4
|
+
import { resolveLiveConfig } from '../src/config';
|
|
5
|
+
import { InProcessBus } from '../src/bus/InProcessBus';
|
|
6
|
+
import { DependencyGraph } from '../src/graph/DependencyGraph';
|
|
7
|
+
import { SubscriptionRegistry } from '../src/graph/SubscriptionRegistry';
|
|
8
|
+
import { ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
9
|
+
import { dependencyContext } from '../src/resource/dependency-context';
|
|
10
|
+
import { LiveEngine, type LiveTransport } from '../src/LiveEngine';
|
|
11
|
+
import type { LiveAuthorizationRequest, LiveAuthorizer } from '../src/auth/authorizer';
|
|
12
|
+
import type { ServerMessage } from '../src/shared/protocol';
|
|
13
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
14
|
+
|
|
15
|
+
let counter = 0;
|
|
16
|
+
|
|
17
|
+
@Controller('/board')
|
|
18
|
+
class BoardController {
|
|
19
|
+
@Get('/')
|
|
20
|
+
@Live({ shared: 'public' })
|
|
21
|
+
board() {
|
|
22
|
+
dependencyContext.current()?.add({ key: 'orm:cards', columns: null });
|
|
23
|
+
return { counter };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class FakeTransport implements LiveTransport {
|
|
28
|
+
readonly sent: { connectionId: string; message: ServerMessage }[] = [];
|
|
29
|
+
|
|
30
|
+
send(connectionId: string, message: ServerMessage): number {
|
|
31
|
+
this.sent.push({ connectionId, message });
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
messagesFor(connectionId: string): ServerMessage[] {
|
|
36
|
+
return this.sent.filter(entry => entry.connectionId === connectionId).map(entry => entry.message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear(): void {
|
|
40
|
+
this.sent.length = 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Denies whichever principals are in `denied` at the moment it is asked. */
|
|
45
|
+
class RosterAuthorizer implements LiveAuthorizer {
|
|
46
|
+
readonly denied = new Set<string>();
|
|
47
|
+
calls = 0;
|
|
48
|
+
|
|
49
|
+
authorize(request: LiveAuthorizationRequest): boolean {
|
|
50
|
+
this.calls += 1;
|
|
51
|
+
return !this.denied.has(String(request.scope.principal));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class ThrowingAuthorizer implements LiveAuthorizer {
|
|
56
|
+
authorize(): boolean {
|
|
57
|
+
throw new Error('authorization backend is down');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function build(authorizer: LiveAuthorizer) {
|
|
62
|
+
const resources = new ResourceRegistry();
|
|
63
|
+
resources.register(BoardController, new BoardController(), directResourceExecutor);
|
|
64
|
+
|
|
65
|
+
const bus = new InProcessBus();
|
|
66
|
+
const transport = new FakeTransport();
|
|
67
|
+
const engine = new LiveEngine(
|
|
68
|
+
resources,
|
|
69
|
+
new DependencyGraph(),
|
|
70
|
+
new SubscriptionRegistry(),
|
|
71
|
+
bus,
|
|
72
|
+
transport,
|
|
73
|
+
resolveLiveConfig({ coalesceMs: 1, unsubGraceMs: 5 }),
|
|
74
|
+
authorizer
|
|
75
|
+
);
|
|
76
|
+
engine.start();
|
|
77
|
+
|
|
78
|
+
return { engine, transport };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 30));
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
counter = 0;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('authorization at subscribe time', () => {
|
|
88
|
+
test('a denied principal gets an error and no data', async () => {
|
|
89
|
+
const authorizer = new RosterAuthorizer();
|
|
90
|
+
authorizer.denied.add('intruder');
|
|
91
|
+
const { engine, transport } = build(authorizer);
|
|
92
|
+
|
|
93
|
+
await engine.subscribe('c1', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'intruder' });
|
|
94
|
+
|
|
95
|
+
const messages = transport.messagesFor('c1');
|
|
96
|
+
expect(messages).toHaveLength(1);
|
|
97
|
+
expect(messages[0]).toMatchObject({ t: 'error', code: 'forbidden' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an authorizer that throws denies instead of leaking', async () => {
|
|
101
|
+
const { engine, transport } = build(new ThrowingAuthorizer());
|
|
102
|
+
|
|
103
|
+
await engine.subscribe('c1', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'ada' });
|
|
104
|
+
|
|
105
|
+
expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'error', code: 'forbidden' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an allowed principal gets the snapshot', async () => {
|
|
109
|
+
const { engine, transport } = build(new RosterAuthorizer());
|
|
110
|
+
|
|
111
|
+
await engine.subscribe('c1', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'ada' });
|
|
112
|
+
|
|
113
|
+
expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'snapshot' });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('authorization after the subscription', () => {
|
|
118
|
+
test('revoking one principal does not disturb the others on a shared instance', async () => {
|
|
119
|
+
const authorizer = new RosterAuthorizer();
|
|
120
|
+
const { engine, transport } = build(authorizer);
|
|
121
|
+
|
|
122
|
+
await engine.subscribe('c1', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'ada' });
|
|
123
|
+
await engine.subscribe('c2', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'linus' });
|
|
124
|
+
transport.clear();
|
|
125
|
+
|
|
126
|
+
authorizer.denied.add('ada');
|
|
127
|
+
engine.invalidate('auth:principal#ada');
|
|
128
|
+
await settle();
|
|
129
|
+
|
|
130
|
+
expect(transport.messagesFor('c1')).toEqual([
|
|
131
|
+
expect.objectContaining({ t: 'error', code: 'forbidden' })
|
|
132
|
+
]);
|
|
133
|
+
expect(transport.messagesFor('c2')).toEqual([]);
|
|
134
|
+
|
|
135
|
+
transport.clear();
|
|
136
|
+
counter = 1;
|
|
137
|
+
engine.invalidate('orm:cards');
|
|
138
|
+
await settle();
|
|
139
|
+
|
|
140
|
+
// The revoked connection is gone; the other one keeps its patches.
|
|
141
|
+
expect(transport.messagesFor('c1')).toEqual([]);
|
|
142
|
+
expect(transport.messagesFor('c2')[0]).toMatchObject({ t: 'patch' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a broad auth key revokes every principal under it', async () => {
|
|
146
|
+
const authorizer = new RosterAuthorizer();
|
|
147
|
+
const { engine, transport } = build(authorizer);
|
|
148
|
+
|
|
149
|
+
await engine.subscribe('c1', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'ada' });
|
|
150
|
+
await engine.subscribe('c2', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'linus' });
|
|
151
|
+
transport.clear();
|
|
152
|
+
|
|
153
|
+
authorizer.denied.add('ada');
|
|
154
|
+
authorizer.denied.add('linus');
|
|
155
|
+
engine.invalidate('auth:principal');
|
|
156
|
+
await settle();
|
|
157
|
+
|
|
158
|
+
expect(transport.messagesFor('c1')[0]).toMatchObject({ t: 'error', code: 'forbidden' });
|
|
159
|
+
expect(transport.messagesFor('c2')[0]).toMatchObject({ t: 'error', code: 'forbidden' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the decision is cached, so a patch does not re-ask the authorizer', async () => {
|
|
163
|
+
const authorizer = new RosterAuthorizer();
|
|
164
|
+
const { engine } = build(authorizer);
|
|
165
|
+
|
|
166
|
+
await engine.subscribe('c1', 's1', 'BoardController.board', { params: {}, query: {} }, { principal: 'ada' });
|
|
167
|
+
const afterSubscribe = authorizer.calls;
|
|
168
|
+
|
|
169
|
+
counter = 1;
|
|
170
|
+
engine.invalidate('orm:cards');
|
|
171
|
+
await settle();
|
|
172
|
+
|
|
173
|
+
expect(authorizer.calls).toBe(afterSubscribe);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
3
|
+
import { PollingTransport } from '../src/client/transport';
|
|
4
|
+
import type { ClientMessage, ServerMessage } from '../src/shared/protocol';
|
|
5
|
+
|
|
6
|
+
class FakeSocket implements LiveSocket {
|
|
7
|
+
onopen: (() => void) | null = null;
|
|
8
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
9
|
+
onclose: (() => void) | null = null;
|
|
10
|
+
onerror: ((error: unknown) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
readonly sent: ClientMessage[] = [];
|
|
13
|
+
closed = false;
|
|
14
|
+
|
|
15
|
+
send(data: string): void {
|
|
16
|
+
this.sent.push(JSON.parse(data));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
close(): void {
|
|
20
|
+
this.closed = true;
|
|
21
|
+
this.onclose?.();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
open(): void {
|
|
25
|
+
this.onopen?.();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
deliver(message: ServerMessage): void {
|
|
29
|
+
this.onmessage?.({ data: JSON.stringify(message) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
subs(): Extract<ClientMessage, { t: 'sub' }>[] {
|
|
33
|
+
return this.sent.filter(m => m.t === 'sub') as any;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function build(options: Partial<ConstructorParameters<typeof LiveClient>[0]> = {}) {
|
|
38
|
+
const socket = new FakeSocket();
|
|
39
|
+
const client = new LiveClient({
|
|
40
|
+
url: 'ws://test/live',
|
|
41
|
+
socketFactory: () => socket,
|
|
42
|
+
unsubGraceMs: 5,
|
|
43
|
+
...options
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return { client, socket };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('LiveClient store', () => {
|
|
50
|
+
test('does not duplicate the first polling GET when the transport opens synchronously', async () => {
|
|
51
|
+
const calls: string[] = [];
|
|
52
|
+
const fetchStub = (async (url: any) => {
|
|
53
|
+
calls.push(String(url));
|
|
54
|
+
return new Response(JSON.stringify([]), {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { 'Content-Type': 'application/json', ETag: '"h1"' }
|
|
57
|
+
});
|
|
58
|
+
}) as unknown as typeof fetch;
|
|
59
|
+
|
|
60
|
+
const client = new LiveClient({
|
|
61
|
+
url: 'http://test/live',
|
|
62
|
+
transportFactory: () => new PollingTransport(
|
|
63
|
+
'http://test',
|
|
64
|
+
{ 'UsersController.list': { method: 'get', path: '/users' } },
|
|
65
|
+
{ intervalMs: 10_000, fetch: fetchStub }
|
|
66
|
+
)
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const store = client.store('UsersController.list', { params: {}, query: {} });
|
|
70
|
+
store.subscribe(() => {});
|
|
71
|
+
|
|
72
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
73
|
+
|
|
74
|
+
expect(calls).toEqual(['http://test/users']);
|
|
75
|
+
client.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('starts pending and subscribes on open', () => {
|
|
79
|
+
const { client, socket } = build();
|
|
80
|
+
const store = client.store('UsersController.list', { params: {}, query: {} });
|
|
81
|
+
store.subscribe(() => {});
|
|
82
|
+
socket.open();
|
|
83
|
+
|
|
84
|
+
expect(store.getSnapshot().pending).toBe(true);
|
|
85
|
+
expect(socket.subs()[0]).toMatchObject({ resource: 'UsersController.list' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a snapshot fills the store and clears pending', () => {
|
|
89
|
+
const { client, socket } = build();
|
|
90
|
+
const store = client.store<{ id: number }[]>('r', { params: {}, query: {} });
|
|
91
|
+
store.subscribe(() => {});
|
|
92
|
+
socket.open();
|
|
93
|
+
|
|
94
|
+
socket.deliver({ t: 'snapshot', sid: socket.subs()[0].sid, rev: 1, hash: 'h1', data: [{ id: 1 }], key: 'id' });
|
|
95
|
+
|
|
96
|
+
expect(store.getSnapshot()).toMatchObject({ pending: false, error: null, stale: false });
|
|
97
|
+
expect(store.getSnapshot().data).toEqual([{ id: 1 }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('getSnapshot is referentially stable until something changes', () => {
|
|
101
|
+
const { client, socket } = build();
|
|
102
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
103
|
+
store.subscribe(() => {});
|
|
104
|
+
socket.open();
|
|
105
|
+
socket.deliver({ t: 'snapshot', sid: socket.subs()[0].sid, rev: 1, hash: 'h1', data: { a: 1 } });
|
|
106
|
+
|
|
107
|
+
const first = store.getSnapshot();
|
|
108
|
+
expect(store.getSnapshot()).toBe(first);
|
|
109
|
+
|
|
110
|
+
socket.deliver({ t: 'patch', sid: socket.subs()[0].sid, from: 1, to: 2, hash: 'h2', ops: [{ op: 'set', path: ['a'], value: 2 }] });
|
|
111
|
+
|
|
112
|
+
expect(store.getSnapshot()).not.toBe(first);
|
|
113
|
+
expect(store.getSnapshot().data).toEqual({ a: 2 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('applies keyed patches keeping untouched rows identical', () => {
|
|
117
|
+
const { client, socket } = build();
|
|
118
|
+
const store = client.store<{ id: number; n: string }[]>('r', { params: {}, query: {} });
|
|
119
|
+
store.subscribe(() => {});
|
|
120
|
+
socket.open();
|
|
121
|
+
const sid = socket.subs()[0].sid;
|
|
122
|
+
socket.deliver({ t: 'snapshot', sid, rev: 1, hash: 'h1', key: 'id', data: [{ id: 1, n: 'a' }, { id: 2, n: 'b' }] });
|
|
123
|
+
|
|
124
|
+
const before = store.getSnapshot().data!;
|
|
125
|
+
socket.deliver({
|
|
126
|
+
t: 'patch', sid, from: 1, to: 2, hash: 'h2',
|
|
127
|
+
ops: [{ op: 'upsert', path: [], key: 2, index: 1, value: { id: 2, n: 'bb' } }]
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(store.getSnapshot().data![0]).toBe(before[0]);
|
|
131
|
+
expect(store.getSnapshot().data![1]).toEqual({ id: 2, n: 'bb' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('asks for a resync when the patch revision does not follow', () => {
|
|
135
|
+
const { client, socket } = build();
|
|
136
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
137
|
+
store.subscribe(() => {});
|
|
138
|
+
socket.open();
|
|
139
|
+
const sid = socket.subs()[0].sid;
|
|
140
|
+
socket.deliver({ t: 'snapshot', sid, rev: 1, hash: 'h1', data: { a: 1 } });
|
|
141
|
+
|
|
142
|
+
socket.deliver({ t: 'patch', sid, from: 5, to: 6, hash: 'h9', ops: [] });
|
|
143
|
+
|
|
144
|
+
expect(socket.sent.some(m => m.t === 'resync' && m.sid === sid)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('marks the store stale without dropping the data', () => {
|
|
148
|
+
const { client, socket } = build();
|
|
149
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
150
|
+
store.subscribe(() => {});
|
|
151
|
+
socket.open();
|
|
152
|
+
const sid = socket.subs()[0].sid;
|
|
153
|
+
socket.deliver({ t: 'snapshot', sid, rev: 1, hash: 'h1', data: { a: 1 } });
|
|
154
|
+
socket.deliver({ t: 'stale', sid, reason: 'db down' });
|
|
155
|
+
|
|
156
|
+
expect(store.getSnapshot()).toMatchObject({ stale: true });
|
|
157
|
+
expect(store.getSnapshot().data).toEqual({ a: 1 });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('surfaces a server error on the store', () => {
|
|
161
|
+
const { client, socket } = build();
|
|
162
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
163
|
+
store.subscribe(() => {});
|
|
164
|
+
socket.open();
|
|
165
|
+
socket.deliver({ t: 'error', sid: socket.subs()[0].sid, code: 'unknown_resource', message: 'nope' });
|
|
166
|
+
|
|
167
|
+
expect(store.getSnapshot()).toMatchObject({ pending: false, error: 'nope' });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('removes a rejected subscription before reconnect while preserving its error', async () => {
|
|
171
|
+
const sockets: FakeSocket[] = [];
|
|
172
|
+
const client = new LiveClient({
|
|
173
|
+
url: 'ws://test/live',
|
|
174
|
+
reconnect: { initialMs: 1, maxMs: 2 },
|
|
175
|
+
socketFactory: () => {
|
|
176
|
+
const socket = new FakeSocket();
|
|
177
|
+
sockets.push(socket);
|
|
178
|
+
return socket;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
183
|
+
store.subscribe(() => {});
|
|
184
|
+
sockets[0].open();
|
|
185
|
+
const sid = sockets[0].subs()[0].sid;
|
|
186
|
+
|
|
187
|
+
sockets[0].deliver({ t: 'error', sid, code: 'forbidden', message: 'nope' });
|
|
188
|
+
expect(store.getSnapshot()).toMatchObject({ pending: false, error: 'nope' });
|
|
189
|
+
|
|
190
|
+
sockets[0].onclose?.();
|
|
191
|
+
|
|
192
|
+
await new Promise(resolve => {
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
sockets[1].open();
|
|
195
|
+
resolve(undefined);
|
|
196
|
+
}, 20);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(sockets[1].subs()).toHaveLength(0);
|
|
200
|
+
expect(store.getSnapshot()).toMatchObject({ pending: false, error: 'nope' });
|
|
201
|
+
client.close();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('two stores with the same resource and inputs share one subscription', () => {
|
|
205
|
+
const { client, socket } = build();
|
|
206
|
+
const a = client.store('r', { params: {}, query: { x: '1' } });
|
|
207
|
+
const b = client.store('r', { params: {}, query: { x: '1' } });
|
|
208
|
+
a.subscribe(() => {});
|
|
209
|
+
b.subscribe(() => {});
|
|
210
|
+
socket.open();
|
|
211
|
+
|
|
212
|
+
expect(a).toBe(b);
|
|
213
|
+
expect(socket.subs()).toHaveLength(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('unsubscribing the last listener sends unsub after the grace period', async () => {
|
|
217
|
+
const { client, socket } = build({ unsubGraceMs: 10 });
|
|
218
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
219
|
+
const off = store.subscribe(() => {});
|
|
220
|
+
socket.open();
|
|
221
|
+
off();
|
|
222
|
+
|
|
223
|
+
expect(socket.sent.some(m => m.t === 'unsub')).toBe(false);
|
|
224
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
225
|
+
expect(socket.sent.some(m => m.t === 'unsub')).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('hydration seeds the store and subscribes with the hash it already has', () => {
|
|
229
|
+
const hydrate = { 'r|{"body":null,"params":{},"query":{}}': { data: { a: 1 }, hash: 'h1' } };
|
|
230
|
+
const { client, socket } = build({ hydrate });
|
|
231
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
232
|
+
store.subscribe(() => {});
|
|
233
|
+
socket.open();
|
|
234
|
+
|
|
235
|
+
expect(store.getSnapshot()).toMatchObject({ pending: false });
|
|
236
|
+
expect(store.getSnapshot().data).toEqual({ a: 1 });
|
|
237
|
+
expect(socket.subs()[0].hash).toBe('h1');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a current response leaves the hydrated data untouched', () => {
|
|
241
|
+
const hydrate = { 'r|{"body":null,"params":{},"query":{}}': { data: { a: 1 }, hash: 'h1' } };
|
|
242
|
+
const { client, socket } = build({ hydrate });
|
|
243
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
244
|
+
store.subscribe(() => {});
|
|
245
|
+
socket.open();
|
|
246
|
+
const before = store.getSnapshot();
|
|
247
|
+
|
|
248
|
+
socket.deliver({ t: 'current', sid: socket.subs()[0].sid, rev: 1, hash: 'h1' });
|
|
249
|
+
|
|
250
|
+
expect(store.getSnapshot().data).toBe(before.data);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('resubscribes every live store on reconnect, carrying the hash', () => {
|
|
254
|
+
const sockets: FakeSocket[] = [];
|
|
255
|
+
const client = new LiveClient({
|
|
256
|
+
url: 'ws://test/live',
|
|
257
|
+
unsubGraceMs: 5,
|
|
258
|
+
reconnect: { initialMs: 1, maxMs: 2 },
|
|
259
|
+
socketFactory: () => {
|
|
260
|
+
const socket = new FakeSocket();
|
|
261
|
+
sockets.push(socket);
|
|
262
|
+
return socket;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const store = client.store('r', { params: {}, query: {} });
|
|
267
|
+
store.subscribe(() => {});
|
|
268
|
+
sockets[0].open();
|
|
269
|
+
socketDeliverSnapshot(sockets[0]);
|
|
270
|
+
|
|
271
|
+
sockets[0].onclose?.();
|
|
272
|
+
|
|
273
|
+
return new Promise<void>(resolve => {
|
|
274
|
+
setTimeout(() => {
|
|
275
|
+
expect(sockets.length).toBeGreaterThan(1);
|
|
276
|
+
sockets[1].open();
|
|
277
|
+
expect(sockets[1].subs()[0].hash).toBe('h1');
|
|
278
|
+
client.close();
|
|
279
|
+
resolve();
|
|
280
|
+
}, 40);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
function socketDeliverSnapshot(socket: FakeSocket): void {
|
|
286
|
+
socket.deliver({ t: 'snapshot', sid: socket.subs()[0].sid, rev: 1, hash: 'h1', data: { a: 1 } });
|
|
287
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
3
|
+
import { WebSocketTransport, type ClientTransport, type TransportHandlers } from '../src/client/transport';
|
|
4
|
+
|
|
5
|
+
function fakeSocket(): LiveSocket & { sent: string[] } {
|
|
6
|
+
const socket = {
|
|
7
|
+
sent: [] as string[],
|
|
8
|
+
send(data: string) { socket.sent.push(data); },
|
|
9
|
+
close() {},
|
|
10
|
+
onopen: null as (() => void) | null,
|
|
11
|
+
onmessage: null as ((event: { data: string }) => void) | null,
|
|
12
|
+
onclose: null as (() => void) | null,
|
|
13
|
+
onerror: null as ((error: unknown) => void) | null
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return socket;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('WebSocketTransport', () => {
|
|
20
|
+
test('reports itself as websocket and forwards the three events', () => {
|
|
21
|
+
const socket = fakeSocket();
|
|
22
|
+
const transport = new WebSocketTransport('ws://x/live', () => socket);
|
|
23
|
+
const seen: string[] = [];
|
|
24
|
+
|
|
25
|
+
transport.start({
|
|
26
|
+
onOpen: () => seen.push('open'),
|
|
27
|
+
onMessage: raw => seen.push(`message:${raw}`),
|
|
28
|
+
onClose: () => seen.push('close')
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(transport.kind).toBe('websocket');
|
|
32
|
+
|
|
33
|
+
socket.onopen?.();
|
|
34
|
+
socket.onmessage?.({ data: 'hi' });
|
|
35
|
+
socket.onclose?.();
|
|
36
|
+
|
|
37
|
+
expect(seen).toEqual(['open', 'message:hi', 'close']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an error counts as a close, because both mean the pipe is gone', () => {
|
|
41
|
+
const socket = fakeSocket();
|
|
42
|
+
const transport = new WebSocketTransport('ws://x/live', () => socket);
|
|
43
|
+
let closes = 0;
|
|
44
|
+
|
|
45
|
+
transport.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => { closes += 1; } });
|
|
46
|
+
socket.onerror?.(new Error('boom'));
|
|
47
|
+
|
|
48
|
+
expect(closes).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('close() does not report a close back, so it cannot trigger a reconnect', () => {
|
|
52
|
+
const socket = fakeSocket();
|
|
53
|
+
const transport = new WebSocketTransport('ws://x/live', () => socket);
|
|
54
|
+
let closes = 0;
|
|
55
|
+
|
|
56
|
+
transport.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => { closes += 1; } });
|
|
57
|
+
transport.close();
|
|
58
|
+
socket.onclose?.();
|
|
59
|
+
|
|
60
|
+
expect(closes).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('LiveClient over a custom transport', () => {
|
|
65
|
+
test('passes configured SSE paths to the default transport ladder', async () => {
|
|
66
|
+
const streamUrls: string[] = [];
|
|
67
|
+
const controlUrls: string[] = [];
|
|
68
|
+
const previousEventSource = (globalThis as any).EventSource;
|
|
69
|
+
const previousFetch = globalThis.fetch;
|
|
70
|
+
|
|
71
|
+
class FakeEventSource {
|
|
72
|
+
onopen: ((event: unknown) => void) | null = null;
|
|
73
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
74
|
+
onerror: ((event: unknown) => void) | null = null;
|
|
75
|
+
|
|
76
|
+
constructor(url: string) {
|
|
77
|
+
streamUrls.push(url);
|
|
78
|
+
queueMicrotask(() => this.onmessage?.({
|
|
79
|
+
data: JSON.stringify({ t: 'ready', cid: 'sse-1' })
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
close(): void {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
(globalThis as any).EventSource = FakeEventSource;
|
|
87
|
+
globalThis.fetch = (async (url: any) => {
|
|
88
|
+
controlUrls.push(String(url));
|
|
89
|
+
return new Response(null, { status: 200 });
|
|
90
|
+
}) as unknown as typeof fetch;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const socket = fakeSocket();
|
|
94
|
+
const client = new LiveClient({
|
|
95
|
+
url: 'ws://test/live',
|
|
96
|
+
httpBaseUrl: 'http://test',
|
|
97
|
+
socketFactory: () => socket,
|
|
98
|
+
ssePath: '/custom/live-stream',
|
|
99
|
+
sseControlPath: '/custom/live-control',
|
|
100
|
+
transportProbeMs: 100
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
client.store('CardsController.list', { params: {}, query: {} }).subscribe(() => {});
|
|
104
|
+
socket.onerror?.(new Error('websocket unavailable'));
|
|
105
|
+
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
107
|
+
|
|
108
|
+
expect(streamUrls).toEqual(['http://test/custom/live-stream']);
|
|
109
|
+
expect(controlUrls).toEqual(['http://test/custom/live-control', 'http://test/custom/live-control']);
|
|
110
|
+
client.close();
|
|
111
|
+
} finally {
|
|
112
|
+
if (previousEventSource === undefined) {
|
|
113
|
+
delete (globalThis as any).EventSource;
|
|
114
|
+
} else {
|
|
115
|
+
(globalThis as any).EventSource = previousEventSource;
|
|
116
|
+
}
|
|
117
|
+
globalThis.fetch = previousFetch;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('uses transportFactory when one is given, and reports its kind', () => {
|
|
122
|
+
const sent: string[] = [];
|
|
123
|
+
let handlers: TransportHandlers | null = null;
|
|
124
|
+
|
|
125
|
+
const custom: ClientTransport = {
|
|
126
|
+
kind: 'sse',
|
|
127
|
+
start(next) { handlers = next; },
|
|
128
|
+
send(raw) { sent.push(raw); },
|
|
129
|
+
close() {}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const client = new LiveClient({ url: 'http://x/live', transportFactory: () => custom });
|
|
133
|
+
|
|
134
|
+
expect(client.transport()).toBeNull();
|
|
135
|
+
|
|
136
|
+
client.store('CardsController.list', { params: {}, query: {} }).subscribe(() => {});
|
|
137
|
+
handlers!.onOpen();
|
|
138
|
+
|
|
139
|
+
expect(client.transport()).toBe('sse');
|
|
140
|
+
expect(sent.some(raw => raw.includes('"t":"hello"'))).toBe(true);
|
|
141
|
+
expect(sent.some(raw => raw.includes('"t":"sub"'))).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('socketFactory still works, unchanged', () => {
|
|
145
|
+
const socket = fakeSocket();
|
|
146
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
147
|
+
|
|
148
|
+
client.store('CardsController.list', { params: {}, query: {} }).subscribe(() => {});
|
|
149
|
+
socket.onopen?.();
|
|
150
|
+
|
|
151
|
+
expect(client.transport()).toBe('websocket');
|
|
152
|
+
expect(socket.sent.some(raw => raw.includes('"t":"sub"'))).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|