@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,293 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
LadderTransport,
|
|
4
|
+
PollingTransport,
|
|
5
|
+
SseClientTransport,
|
|
6
|
+
routeIndex,
|
|
7
|
+
type ClientTransport,
|
|
8
|
+
type TransportHandlers
|
|
9
|
+
} from '../src/client/transport';
|
|
10
|
+
|
|
11
|
+
const GENERATED_ROUTES = {
|
|
12
|
+
cards: {
|
|
13
|
+
list: { method: 'get', path: '/cards', resourceId: 'CardsController.list', live: { shared: 'public' } },
|
|
14
|
+
one: { method: 'get', path: '/cards/:id', resourceId: 'CardsController.one', live: { shared: 'public' } }
|
|
15
|
+
},
|
|
16
|
+
health: {
|
|
17
|
+
check: { method: 'get', path: '/health' }
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function stubTransport(kind: ClientTransport['kind'], behaviour: 'open' | 'fail'): ClientTransport & { started: number } {
|
|
22
|
+
const transport = {
|
|
23
|
+
kind,
|
|
24
|
+
started: 0,
|
|
25
|
+
start(handlers: TransportHandlers) {
|
|
26
|
+
transport.started += 1;
|
|
27
|
+
queueMicrotask(() => (behaviour === 'open' ? handlers.onOpen() : handlers.onClose()));
|
|
28
|
+
},
|
|
29
|
+
send() {},
|
|
30
|
+
close() {}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return transport;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('routeIndex', () => {
|
|
37
|
+
test('flattens the generated tree into resourceId to path', () => {
|
|
38
|
+
expect(routeIndex(GENERATED_ROUTES)).toEqual({
|
|
39
|
+
'CardsController.list': { method: 'get', path: '/cards' },
|
|
40
|
+
'CardsController.one': { method: 'get', path: '/cards/:id' }
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('skips routes that are not live, because polling one means nothing', () => {
|
|
45
|
+
expect(routeIndex(GENERATED_ROUTES)['HealthController.check']).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('skips live POST routes because polling only has a GET body', () => {
|
|
49
|
+
expect(routeIndex({
|
|
50
|
+
search: {
|
|
51
|
+
run: { method: 'post', path: '/cards/search', resourceId: 'CardsController.search', live: {} }
|
|
52
|
+
}
|
|
53
|
+
})['CardsController.search']).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an empty or absent tree is an empty index, not a throw', () => {
|
|
57
|
+
expect(routeIndex(undefined)).toEqual({});
|
|
58
|
+
expect(routeIndex({})).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('LadderTransport', () => {
|
|
63
|
+
test('stays on the first rung when it opens', async () => {
|
|
64
|
+
const socket = stubTransport('websocket', 'open');
|
|
65
|
+
const sse = stubTransport('sse', 'open');
|
|
66
|
+
const ladder = new LadderTransport([() => socket, () => sse], { probeMs: 50 });
|
|
67
|
+
let opened = false;
|
|
68
|
+
|
|
69
|
+
ladder.start({ onOpen: () => { opened = true; }, onMessage: () => {}, onClose: () => {} });
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
71
|
+
|
|
72
|
+
expect(opened).toBe(true);
|
|
73
|
+
expect(ladder.kind).toBe('websocket');
|
|
74
|
+
expect(sse.started).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('descends when a rung fails to open', async () => {
|
|
78
|
+
const socket = stubTransport('websocket', 'fail');
|
|
79
|
+
const sse = stubTransport('sse', 'open');
|
|
80
|
+
const ladder = new LadderTransport([() => socket, () => sse], { probeMs: 50 });
|
|
81
|
+
let opened = false;
|
|
82
|
+
|
|
83
|
+
ladder.start({ onOpen: () => { opened = true; }, onMessage: () => {}, onClose: () => {} });
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
85
|
+
|
|
86
|
+
expect(opened).toBe(true);
|
|
87
|
+
expect(ladder.kind).toBe('sse');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('descends when a rung neither opens nor fails within the probe window', async () => {
|
|
91
|
+
const stuck: ClientTransport = { kind: 'websocket', start() {}, send() {}, close() {} };
|
|
92
|
+
const sse = stubTransport('sse', 'open');
|
|
93
|
+
const ladder = new LadderTransport([() => stuck, () => sse], { probeMs: 10 });
|
|
94
|
+
let opened = false;
|
|
95
|
+
|
|
96
|
+
ladder.start({ onOpen: () => { opened = true; }, onMessage: () => {}, onClose: () => {} });
|
|
97
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
98
|
+
|
|
99
|
+
// A corporate proxy that swallows the upgrade without answering is
|
|
100
|
+
// the case that makes the timeout necessary rather than tidy.
|
|
101
|
+
expect(opened).toBe(true);
|
|
102
|
+
expect(ladder.kind).toBe('sse');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('reports a real close once it is settled, so the client can reconnect', async () => {
|
|
106
|
+
let handlers: TransportHandlers | null = null;
|
|
107
|
+
const flaky: ClientTransport = {
|
|
108
|
+
kind: 'websocket',
|
|
109
|
+
start(next) { handlers = next; queueMicrotask(() => next.onOpen()); },
|
|
110
|
+
send() {},
|
|
111
|
+
close() {}
|
|
112
|
+
};
|
|
113
|
+
const ladder = new LadderTransport([() => flaky], { probeMs: 50 });
|
|
114
|
+
let closes = 0;
|
|
115
|
+
|
|
116
|
+
ladder.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => { closes += 1; } });
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
118
|
+
handlers!.onClose();
|
|
119
|
+
|
|
120
|
+
expect(closes).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a ladder whose every rung fails reports one close, not one per rung', async () => {
|
|
124
|
+
const ladder = new LadderTransport(
|
|
125
|
+
[() => stubTransport('websocket', 'fail'), () => stubTransport('sse', 'fail')],
|
|
126
|
+
{ probeMs: 10 }
|
|
127
|
+
);
|
|
128
|
+
let closes = 0;
|
|
129
|
+
|
|
130
|
+
ladder.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => { closes += 1; } });
|
|
131
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
132
|
+
|
|
133
|
+
expect(closes).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('reports one close when a rung fails synchronously', async () => {
|
|
137
|
+
const ladder = new LadderTransport([() => new SseClientTransport('http://x')], { probeMs: 10 });
|
|
138
|
+
let closes = 0;
|
|
139
|
+
|
|
140
|
+
ladder.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => { closes += 1; } });
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
142
|
+
|
|
143
|
+
expect(closes).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('SseClientTransport', () => {
|
|
148
|
+
test('reports an unavailable EventSource as a failed rung', () => {
|
|
149
|
+
const transport = new SseClientTransport('http://x');
|
|
150
|
+
let closes = 0;
|
|
151
|
+
|
|
152
|
+
expect(() => transport.start({
|
|
153
|
+
onOpen: () => {},
|
|
154
|
+
onMessage: () => {},
|
|
155
|
+
onClose: () => { closes += 1; }
|
|
156
|
+
})).not.toThrow();
|
|
157
|
+
|
|
158
|
+
expect(closes).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('PollingTransport', () => {
|
|
163
|
+
test('turns a sub into a conditional GET and the answer into a snapshot', async () => {
|
|
164
|
+
const calls: { url: string; headers: Record<string, string> }[] = [];
|
|
165
|
+
const fetchStub = (async (url: any, init: any) => {
|
|
166
|
+
calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
|
|
167
|
+
return new Response(JSON.stringify([{ id: 1 }]), {
|
|
168
|
+
status: 200,
|
|
169
|
+
headers: { 'Content-Type': 'application/json', ETag: '"abc"' }
|
|
170
|
+
});
|
|
171
|
+
}) as unknown as typeof fetch;
|
|
172
|
+
|
|
173
|
+
const transport = new PollingTransport(
|
|
174
|
+
'http://x',
|
|
175
|
+
routeIndex(GENERATED_ROUTES),
|
|
176
|
+
{ intervalMs: 10_000, fetch: fetchStub }
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const received: any[] = [];
|
|
180
|
+
transport.start({ onOpen: () => {}, onMessage: raw => received.push(JSON.parse(raw)), onClose: () => {} });
|
|
181
|
+
transport.send(JSON.stringify({ t: 'hello', v: 1, token: 'poll-secret' }));
|
|
182
|
+
transport.send(JSON.stringify({
|
|
183
|
+
t: 'sub',
|
|
184
|
+
sid: 's1',
|
|
185
|
+
resource: 'CardsController.list',
|
|
186
|
+
inputs: { params: {}, query: { done: 'true' } }
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
190
|
+
|
|
191
|
+
expect(calls[0].url).toBe('http://x/cards?done=true');
|
|
192
|
+
expect(calls[0].headers['X-Carno-Live-Poll']).toBe('1');
|
|
193
|
+
expect(calls[0].headers['X-Carno-Live-Token']).toBe('poll-secret');
|
|
194
|
+
expect(calls[0].headers['X-Carno-Live-Connection']).toMatch(/^poll:/);
|
|
195
|
+
expect(received[0]).toMatchObject({ t: 'snapshot', sid: 's1', data: [{ id: 1 }], hash: 'abc' });
|
|
196
|
+
transport.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('sends If-None-Match on the second poll and emits nothing on 304', async () => {
|
|
200
|
+
let call = 0;
|
|
201
|
+
const fetchStub = (async (_url: any, init: any) => {
|
|
202
|
+
call += 1;
|
|
203
|
+
|
|
204
|
+
if (call === 1) {
|
|
205
|
+
return new Response(JSON.stringify([{ id: 1 }]), {
|
|
206
|
+
status: 200,
|
|
207
|
+
headers: { 'Content-Type': 'application/json', ETag: '"abc"' }
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
expect((init.headers as Record<string, string>)['If-None-Match']).toBe('"abc"');
|
|
212
|
+
return new Response(null, { status: 304 });
|
|
213
|
+
}) as unknown as typeof fetch;
|
|
214
|
+
|
|
215
|
+
const transport = new PollingTransport(
|
|
216
|
+
'http://x',
|
|
217
|
+
routeIndex(GENERATED_ROUTES),
|
|
218
|
+
{ intervalMs: 5, fetch: fetchStub }
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const received: any[] = [];
|
|
222
|
+
transport.start({ onOpen: () => {}, onMessage: raw => received.push(JSON.parse(raw)), onClose: () => {} });
|
|
223
|
+
transport.send(JSON.stringify({
|
|
224
|
+
t: 'sub', sid: 's1', resource: 'CardsController.list', inputs: { params: {}, query: {} }
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
228
|
+
|
|
229
|
+
// 304 means the screen is already right. Emitting a snapshot would
|
|
230
|
+
// hand the store a new object for identical content and re-render.
|
|
231
|
+
expect(received.filter(message => message.t === 'snapshot').length).toBe(1);
|
|
232
|
+
transport.close();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('fills :params from the inputs', async () => {
|
|
236
|
+
const urls: string[] = [];
|
|
237
|
+
const fetchStub = (async (url: any) => {
|
|
238
|
+
urls.push(String(url));
|
|
239
|
+
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
240
|
+
}) as unknown as typeof fetch;
|
|
241
|
+
|
|
242
|
+
const transport = new PollingTransport('http://x', routeIndex(GENERATED_ROUTES), { intervalMs: 10_000, fetch: fetchStub });
|
|
243
|
+
transport.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => {} });
|
|
244
|
+
transport.send(JSON.stringify({
|
|
245
|
+
t: 'sub', sid: 's1', resource: 'CardsController.one', inputs: { params: { id: '42' }, query: {} }
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
249
|
+
|
|
250
|
+
expect(urls[0]).toBe('http://x/cards/42');
|
|
251
|
+
transport.close();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('an unsub stops the polling for that subscription', async () => {
|
|
255
|
+
let calls = 0;
|
|
256
|
+
const fetchStub = (async () => {
|
|
257
|
+
calls += 1;
|
|
258
|
+
return new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
259
|
+
}) as unknown as typeof fetch;
|
|
260
|
+
|
|
261
|
+
const transport = new PollingTransport('http://x', routeIndex(GENERATED_ROUTES), { intervalMs: 5, fetch: fetchStub });
|
|
262
|
+
transport.start({ onOpen: () => {}, onMessage: () => {}, onClose: () => {} });
|
|
263
|
+
transport.send(JSON.stringify({
|
|
264
|
+
t: 'sub', sid: 's1', resource: 'CardsController.list', inputs: { params: {}, query: {} }
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
268
|
+
transport.send(JSON.stringify({ t: 'unsub', sid: 's1' }));
|
|
269
|
+
const afterUnsub = calls;
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
271
|
+
|
|
272
|
+
expect(calls).toBe(afterUnsub);
|
|
273
|
+
transport.close();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a resource the index does not know is an error the client can show', async () => {
|
|
277
|
+
const transport = new PollingTransport('http://x', {}, { intervalMs: 10_000 });
|
|
278
|
+
const received: any[] = [];
|
|
279
|
+
|
|
280
|
+
transport.start({ onOpen: () => {}, onMessage: raw => received.push(JSON.parse(raw)), onClose: () => {} });
|
|
281
|
+
transport.send(JSON.stringify({
|
|
282
|
+
t: 'sub', sid: 's1', resource: 'Unknown.thing', inputs: { params: {}, query: {} }
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
286
|
+
|
|
287
|
+
// Silence here would be a screen that stays pending forever with no
|
|
288
|
+
// explanation. Say what is missing and how to supply it.
|
|
289
|
+
expect(received[0]).toMatchObject({ t: 'error', sid: 's1', code: 'no_route' });
|
|
290
|
+
expect(received[0].message).toMatch(/routes/);
|
|
291
|
+
transport.close();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { dropLiveConnection, handleMessage, LiveGateway } from '../src/transport/LiveGateway';
|
|
3
|
+
import { SocketTransport } from '../src/transport/SocketTransport';
|
|
4
|
+
import { ConnectionScopeResolver } from '../src/transport/scope-resolver';
|
|
5
|
+
import { getLiveRuntime, resetLiveRuntime, setLiveRuntime } from '../src/runtime';
|
|
6
|
+
import type { LiveScope } from '../src/shared/inputs';
|
|
7
|
+
import type { ServerMessage } from '../src/shared/protocol';
|
|
8
|
+
|
|
9
|
+
class FakeSocket {
|
|
10
|
+
sent: string[] = [];
|
|
11
|
+
result = 7;
|
|
12
|
+
|
|
13
|
+
constructor(public readonly id: string) {}
|
|
14
|
+
|
|
15
|
+
send(message: string): number {
|
|
16
|
+
this.sent.push(message);
|
|
17
|
+
return this.result;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MESSAGE: ServerMessage = { t: 'stale', sid: 's1', reason: 'test' };
|
|
22
|
+
|
|
23
|
+
describe('SocketTransport', () => {
|
|
24
|
+
test('serializes the message to the registered socket', () => {
|
|
25
|
+
const transport = new SocketTransport();
|
|
26
|
+
const socket = new FakeSocket('c1');
|
|
27
|
+
transport.add(socket as any);
|
|
28
|
+
|
|
29
|
+
expect(transport.send('c1', MESSAGE)).toBe(7);
|
|
30
|
+
expect(JSON.parse(socket.sent[0])).toEqual(MESSAGE as any);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('reports a dropped send for an unknown connection', () => {
|
|
34
|
+
expect(new SocketTransport().send('ghost', MESSAGE)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('reports a dropped send when the socket throws', () => {
|
|
38
|
+
const transport = new SocketTransport();
|
|
39
|
+
transport.add({ id: 'c1', send: () => { throw new Error('closed'); } } as any);
|
|
40
|
+
|
|
41
|
+
expect(transport.send('c1', MESSAGE)).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('remove stops delivery', () => {
|
|
45
|
+
const transport = new SocketTransport();
|
|
46
|
+
const socket = new FakeSocket('c1');
|
|
47
|
+
transport.add(socket as any);
|
|
48
|
+
transport.remove('c1');
|
|
49
|
+
|
|
50
|
+
expect(transport.send('c1', MESSAGE)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('ConnectionScopeResolver', () => {
|
|
55
|
+
test('makes each connection its own principal, which shares nothing', async () => {
|
|
56
|
+
const resolver = new ConnectionScopeResolver();
|
|
57
|
+
|
|
58
|
+
expect(await resolver.resolve({ connectionId: 'c1' })).toEqual({ principal: 'c1' });
|
|
59
|
+
expect(await resolver.resolve({ connectionId: 'c2' })).toEqual({ principal: 'c2' });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('LiveGateway', () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
resetLiveRuntime();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a sub sent immediately after hello uses the resolved scope', async () => {
|
|
69
|
+
let releaseHello!: () => void;
|
|
70
|
+
const helloGate = new Promise<void>(resolve => {
|
|
71
|
+
releaseHello = resolve;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let releaseSub!: () => void;
|
|
75
|
+
const subSeen = new Promise<void>(resolve => {
|
|
76
|
+
releaseSub = resolve;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let usedScope: LiveScope | undefined;
|
|
80
|
+
|
|
81
|
+
setLiveRuntime({
|
|
82
|
+
engine: {
|
|
83
|
+
subscribe: async (_connectionId: string, _sid: string, _resource: string, _inputs: unknown, scope: LiveScope) => {
|
|
84
|
+
usedScope = scope;
|
|
85
|
+
releaseSub();
|
|
86
|
+
},
|
|
87
|
+
unsubscribe() {},
|
|
88
|
+
dropConnection() {},
|
|
89
|
+
resync: async () => {}
|
|
90
|
+
} as any,
|
|
91
|
+
transport: new SocketTransport(),
|
|
92
|
+
resolver: {
|
|
93
|
+
resolve: async () => {
|
|
94
|
+
await helloGate;
|
|
95
|
+
return { principal: 'user-1', tenant: 'acme' };
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
scopes: new Map(),
|
|
99
|
+
handshakes: new Set()
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const gateway = new LiveGateway();
|
|
103
|
+
const socket = new FakeSocket('c1');
|
|
104
|
+
gateway.onOpen(socket as any);
|
|
105
|
+
gateway.onMessage(socket as any, JSON.stringify({ t: 'hello', v: 1, token: 't' }));
|
|
106
|
+
gateway.onMessage(
|
|
107
|
+
socket as any,
|
|
108
|
+
JSON.stringify({ t: 'sub', sid: 's1', resource: 'UsersController.list', inputs: { params: {}, query: {} } })
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Let both handlers start. Hello parks on the resolver; without
|
|
112
|
+
// per-connection ordering, sub would already have subscribed.
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
114
|
+
releaseHello();
|
|
115
|
+
await subSeen;
|
|
116
|
+
|
|
117
|
+
expect(usedScope).toEqual({ principal: 'user-1', tenant: 'acme' });
|
|
118
|
+
gateway.onClose(socket as any);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('rejects a second hello without replacing the scope of existing bindings', async () => {
|
|
122
|
+
let resolverCalls = 0;
|
|
123
|
+
const usedScopes: LiveScope[] = [];
|
|
124
|
+
|
|
125
|
+
setLiveRuntime({
|
|
126
|
+
engine: {
|
|
127
|
+
subscribe: async (
|
|
128
|
+
_connectionId: string,
|
|
129
|
+
_sid: string,
|
|
130
|
+
_resource: string,
|
|
131
|
+
_inputs: unknown,
|
|
132
|
+
scope: LiveScope
|
|
133
|
+
) => {
|
|
134
|
+
usedScopes.push(scope);
|
|
135
|
+
},
|
|
136
|
+
unsubscribe() {},
|
|
137
|
+
dropConnection() {},
|
|
138
|
+
resync: async () => {}
|
|
139
|
+
} as any,
|
|
140
|
+
transport: new SocketTransport(),
|
|
141
|
+
resolver: {
|
|
142
|
+
resolve: async ({ token }) => {
|
|
143
|
+
resolverCalls++;
|
|
144
|
+
return { principal: token ?? 'anonymous' };
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
scopes: new Map(),
|
|
148
|
+
handshakes: new Set()
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const gateway = new LiveGateway();
|
|
152
|
+
const socket = new FakeSocket('c-repeat');
|
|
153
|
+
gateway.onOpen(socket as any);
|
|
154
|
+
|
|
155
|
+
await handleMessage('c-repeat', JSON.stringify({ t: 'hello', v: 1, token: 'first' }));
|
|
156
|
+
await handleMessage(
|
|
157
|
+
'c-repeat',
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
t: 'sub',
|
|
160
|
+
sid: 's1',
|
|
161
|
+
resource: 'UsersController.list',
|
|
162
|
+
inputs: { params: {}, query: {} }
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
await handleMessage('c-repeat', JSON.stringify({ t: 'hello', v: 1, token: 'second' }));
|
|
166
|
+
await handleMessage(
|
|
167
|
+
'c-repeat',
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
t: 'sub',
|
|
170
|
+
sid: 's2',
|
|
171
|
+
resource: 'UsersController.list',
|
|
172
|
+
inputs: { params: {}, query: {} }
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(resolverCalls).toBe(1);
|
|
177
|
+
expect(getLiveRuntime().scopes.get('c-repeat')).toEqual({ principal: 'first' });
|
|
178
|
+
expect(usedScopes).toEqual([
|
|
179
|
+
{ principal: 'first' },
|
|
180
|
+
{ principal: 'first' }
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
gateway.onClose(socket as any);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('close drops a queued sub so a slow hello cannot resurrect the connection', async () => {
|
|
187
|
+
let releaseHello!: () => void;
|
|
188
|
+
const helloGate = new Promise<void>(resolve => {
|
|
189
|
+
releaseHello = resolve;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
let subscribed = false;
|
|
193
|
+
|
|
194
|
+
setLiveRuntime({
|
|
195
|
+
engine: {
|
|
196
|
+
subscribe: async () => {
|
|
197
|
+
subscribed = true;
|
|
198
|
+
},
|
|
199
|
+
unsubscribe() {},
|
|
200
|
+
dropConnection() {},
|
|
201
|
+
resync: async () => {}
|
|
202
|
+
} as any,
|
|
203
|
+
transport: new SocketTransport(),
|
|
204
|
+
resolver: {
|
|
205
|
+
resolve: async () => {
|
|
206
|
+
await helloGate;
|
|
207
|
+
return { principal: 'user-1' };
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
scopes: new Map(),
|
|
211
|
+
handshakes: new Set()
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const gateway = new LiveGateway();
|
|
215
|
+
const socket = new FakeSocket('c1');
|
|
216
|
+
gateway.onOpen(socket as any);
|
|
217
|
+
gateway.onMessage(socket as any, JSON.stringify({ t: 'hello', v: 1, token: 't' }));
|
|
218
|
+
gateway.onMessage(
|
|
219
|
+
socket as any,
|
|
220
|
+
JSON.stringify({ t: 'sub', sid: 's1', resource: 'UsersController.list', inputs: { params: {}, query: {} } })
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
224
|
+
gateway.onClose(socket as any);
|
|
225
|
+
releaseHello();
|
|
226
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
227
|
+
|
|
228
|
+
expect(subscribed).toBe(false);
|
|
229
|
+
expect(getLiveRuntime().scopes.has('c1')).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('dropLiveConnection drops a queued sub, which is what SSE cancel uses', async () => {
|
|
233
|
+
let releaseHello!: () => void;
|
|
234
|
+
const helloGate = new Promise<void>(resolve => {
|
|
235
|
+
releaseHello = resolve;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
let subscribed = false;
|
|
239
|
+
|
|
240
|
+
setLiveRuntime({
|
|
241
|
+
engine: {
|
|
242
|
+
subscribe: async () => {
|
|
243
|
+
subscribed = true;
|
|
244
|
+
},
|
|
245
|
+
unsubscribe() {},
|
|
246
|
+
dropConnection() {},
|
|
247
|
+
resync: async () => {}
|
|
248
|
+
} as any,
|
|
249
|
+
transport: new SocketTransport(),
|
|
250
|
+
resolver: {
|
|
251
|
+
resolve: async () => {
|
|
252
|
+
await helloGate;
|
|
253
|
+
return { principal: 'user-1' };
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
scopes: new Map([['sse:1', { principal: 'sse:1' }]]),
|
|
257
|
+
handshakes: new Set()
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
void handleMessage('sse:1', JSON.stringify({ t: 'hello', v: 1, token: 't' }));
|
|
261
|
+
void handleMessage(
|
|
262
|
+
'sse:1',
|
|
263
|
+
JSON.stringify({ t: 'sub', sid: 's1', resource: 'UsersController.list', inputs: { params: {}, query: {} } })
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
267
|
+
dropLiveConnection('sse:1');
|
|
268
|
+
releaseHello();
|
|
269
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
270
|
+
|
|
271
|
+
expect(subscribed).toBe(false);
|
|
272
|
+
expect(getLiveRuntime().scopes.has('sse:1')).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useLive, useLiveAction } from '../../src/client/react';
|
|
2
|
+
import type { LiveDescriptor } from '../../src/shared/descriptor';
|
|
3
|
+
|
|
4
|
+
interface Card {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface CreateCardDto {
|
|
10
|
+
title: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare const list: LiveDescriptor<{ query: { status?: string }; response: Card[] }>;
|
|
14
|
+
declare const create: (dto: CreateCardDto) => Promise<Card>;
|
|
15
|
+
|
|
16
|
+
export function typeChecks(): void {
|
|
17
|
+
const state = useLive(list, { query: { status: 'open' } });
|
|
18
|
+
|
|
19
|
+
// `data` is Card[] | undefined, so this compiles and `title` is a string.
|
|
20
|
+
const first: string | undefined = state.data?.[0]?.title;
|
|
21
|
+
void first;
|
|
22
|
+
|
|
23
|
+
const send = useLiveAction(create, {
|
|
24
|
+
optimistic: [{
|
|
25
|
+
on: list,
|
|
26
|
+
apply: (draft, dto) => {
|
|
27
|
+
// `draft` is Card[] and `dto` is CreateCardDto, both inferred.
|
|
28
|
+
draft.push({ id: 'temp', title: dto.title });
|
|
29
|
+
}
|
|
30
|
+
}]
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
void send({ title: 'inferred' });
|
|
34
|
+
}
|